From cd236a5dae4a4a4c4605e293498f5a67ceaeeaa8 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 27 May 2026 11:53:52 -0600 Subject: [PATCH 1/8] PlatformAudio: integration and unit tests --- AGENTS.md | 1 + CMakeLists.txt | 1 + include/livekit/livekit.h | 3 +- include/livekit/local_audio_track.h | 50 +++- include/livekit/platform_audio.h | 269 ++++++++++++++++++ src/local_audio_track.cpp | 45 ++- src/platform_audio.cpp | 195 +++++++++++++ src/tests/integration/test_platform_audio.cpp | 239 ++++++++++++++++ src/tests/unit/test_platform_audio.cpp | 174 +++++++++++ 9 files changed, 959 insertions(+), 18 deletions(-) create mode 100644 include/livekit/platform_audio.h create mode 100644 src/platform_audio.cpp create mode 100644 src/tests/integration/test_platform_audio.cpp create mode 100644 src/tests/unit/test_platform_audio.cpp diff --git a/AGENTS.md b/AGENTS.md index c03c458a..6d9a0d44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,7 @@ All `RoomDelegate` callbacks and stream handler callbacks (e.g., `registerTextSt | `SubscriptionThreadDispatcher` | Yes | Internal `std::mutex` protects registrations and active readers. Thread joins happen outside the lock. | | `AudioStream` / `VideoStream` / `DataTrackStream` | Yes | Internal `std::mutex` + `condition_variable` coordinate the FFI producer thread and the consumer reader thread. | | `AudioSource::captureFrame` | No | Not safe to call concurrently from multiple threads. | +| `PlatformAudio` / `PlatformAudioSource` | Yes | Thin `sendRequest` wrappers over immutable FFI handle state; destruction and move operations must be externally synchronized. | | `VideoSource::captureFrame` | No | Not safe to call concurrently from multiple threads. | | `LocalAudioTrack` / `LocalVideoTrack` | No | Thin `sendRequest` wrappers with no internal synchronization. | | `LocalDataTrack::tryPush` | No | Thin `sendRequest` wrapper with no internal synchronization. | diff --git a/CMakeLists.txt b/CMakeLists.txt index ebfeeeb9..be4a8e5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -385,6 +385,7 @@ add_library(livekit SHARED src/logging.cpp src/local_audio_track.cpp src/local_data_track.cpp + src/platform_audio.cpp src/remote_audio_track.cpp src/remote_data_track.cpp src/room.cpp diff --git a/include/livekit/livekit.h b/include/livekit/livekit.h index e02ce67e..4abcb2d5 100644 --- a/include/livekit/livekit.h +++ b/include/livekit/livekit.h @@ -28,6 +28,7 @@ #include "livekit/local_video_track.h" #include "livekit/logging.h" #include "livekit/participant.h" +#include "livekit/platform_audio.h" #include "livekit/remote_participant.h" #include "livekit/remote_track_publication.h" #include "livekit/room.h" @@ -60,4 +61,4 @@ LIVEKIT_API bool initialize(const LogLevel& level = LogLevel::Info); /// After shutdown, you may call initialize() again. LIVEKIT_API void shutdown(); -} // namespace livekit \ No newline at end of file +} // namespace livekit diff --git a/include/livekit/local_audio_track.h b/include/livekit/local_audio_track.h index 2797fcde..21c9deee 100644 --- a/include/livekit/local_audio_track.h +++ b/include/livekit/local_audio_track.h @@ -31,12 +31,15 @@ class OwnedTrack; } class AudioSource; +class PlatformAudioSource; /// Represents a user-provided audio track sourced from the local device. /// -/// `LocalAudioTrack` is used to publish microphone audio (or any custom -/// audio source) to a LiveKit room. It wraps a platform-specific audio -/// source and exposes simple controls such as `mute()` and `unmute()`. +/// `LocalAudioTrack` is used to publish microphone audio or any custom audio +/// source to a LiveKit room. It wraps a platform-specific audio +/// source and exposes simple controls such as `mute()` and `unmute()`. +/// Muting a local audio track stops transmitting audio to the room, but +/// the underlying source may continue capturing depending on platform behavior. /// /// Typical usage: /// @@ -52,6 +55,8 @@ class AudioSource; /// /// The track name provided during creation is visible to remote /// participants and can be used for debugging or UI display. +/// @note Thread-safety: Not thread-safe. This is a thin FFI wrapper with no +/// internal synchronization. class LIVEKIT_API LocalAudioTrack : public Track { public: /// Creates a new local audio track backed by the given `AudioSource`. @@ -63,30 +68,55 @@ class LIVEKIT_API LocalAudioTrack : public Track { /// directly for frame capture. /// /// @return A shared pointer to the newly constructed `LocalAudioTrack`. + /// @throws std::invalid_argument If \p source is null. + /// @throws std::runtime_error If the FFI request fails. static std::shared_ptr createLocalAudioTrack(const std::string& name, const std::shared_ptr& source); + /// Creates a new local audio track backed by the given `PlatformAudioSource`. + /// + /// @param name Human-readable name for the track. This may appear to + /// remote participants and in analytics/debug logs. + /// @param source The platform source that captures microphone audio + /// automatically through WebRTC's Audio Device Module. + /// + /// @return A shared pointer to the newly constructed `LocalAudioTrack`. + /// @throws std::invalid_argument If \p source is null. + /// @throws std::runtime_error If the FFI request fails. + /// + /// @note Thread-safety: Not thread-safe. This is a thin FFI wrapper with no + /// internal synchronization. + static std::shared_ptr createLocalAudioTrack(const std::string& name, + const std::shared_ptr& source); + /// Mutes the audio track. /// /// A muted track stops sending audio to the room, but the track remains /// published and can be unmuted later without renegotiation. + /// + /// @throws std::runtime_error If the FFI request fails. void mute(); - /// Unmutes the audio track and resumes sending audio to the room. + /// Unmute the audio track. + /// + /// Resumes sending audio to the room. + /// + /// @throws std::runtime_error If the FFI request fails. void unmute(); - /// Returns a human-readable string representation of the track, - /// including its SID and name. Useful for debugging and logging. + /// Return a human-readable string representation of the track. + /// + /// @return String containing the track SID and name. std::string toString() const; /// Returns the publication that owns this track, or nullptr if the track is /// not published. std::shared_ptr publication() const noexcept { return local_publication_; } - /// Sets the publication that owns this track. - /// Note: std::move on a const& silently falls back to a copy, so we assign - /// directly. Changing the virtual signature to take by value would enable - /// a true move but is an API-breaking change hence left for a future revision. + /// Set the publication that owns this track. + /// + /// @param publication Publication that owns this track, or nullptr to clear + /// the association. void setPublication(const std::shared_ptr& publication) noexcept override { local_publication_ = publication; } diff --git a/include/livekit/platform_audio.h b/include/livekit/platform_audio.h new file mode 100644 index 00000000..b3ee0060 --- /dev/null +++ b/include/livekit/platform_audio.h @@ -0,0 +1,269 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include "livekit/ffi_handle.h" +#include "livekit/visibility.h" + +namespace livekit { + +/// Internal shared state for platform audio handles. +/// +/// This forward declaration is exposed only so public wrapper types can hold a +/// shared implementation pointer. +/// +/// @note Thread-safety: Thread-safe. Instances are managed internally by +/// PlatformAudio. +struct PlatformAudioState; + +/// Information about a platform audio device. +/// +/// Device indices may change when audio hardware is added or removed. Prefer +/// the stable `id` value when selecting a device. +/// +/// @note Thread-safety: Thread-safe. This is an aggregate value type with no +/// internal shared state. +struct AudioDeviceInfo { + /// Current device index. + std::uint32_t index = 0; + + /// Device name reported by the operating system. + std::string name; + + /// Platform-specific stable device identifier. + std::string id; +}; + +/// Audio processing options for platform microphone capture. +/// +/// The default values enable WebRTC's voice processing path for typical +/// microphone publishing. +/// +/// @note Thread-safety: Thread-safe. This is an aggregate value type with no +/// internal shared state. +struct PlatformAudioOptions { + /// Enable acoustic echo cancellation. + bool echo_cancellation = true; + + /// Enable background noise suppression. + bool noise_suppression = true; + + /// Enable automatic gain control. + bool auto_gain_control = true; + + /// Prefer hardware audio processing when the platform provides it. + bool prefer_hardware = false; +}; + +/// Error raised when platform audio setup or device operations fail. +/// +/// @note Thread-safety: Thread-safe. Instances are immutable after +/// construction. +class LIVEKIT_API PlatformAudioError : public std::runtime_error { +public: + /// Create a platform audio error. + /// + /// @param message Human-readable error message. + /// + /// @note Thread-safety: Thread-safe. Instances are immutable after + /// construction. + explicit PlatformAudioError(const std::string& message) : std::runtime_error(message) {} +}; + +/// Audio source backed by WebRTC's platform Audio Device Module. +/// +/// A PlatformAudioSource captures microphone audio automatically. Unlike +/// AudioSource, callers do not push frames with captureFrame(). +/// +/// @note Thread-safety: Thread-safe. The source owns an immutable FFI handle +/// and keeps the shared PlatformAudio state alive. +class LIVEKIT_API PlatformAudioSource { +public: + /// Copy construction is disabled. + /// + /// @param other Source to copy from. + /// + /// @note Thread-safety: Not thread-safe. This operation is deleted. + PlatformAudioSource(const PlatformAudioSource& other) = delete; + + /// Copy assignment is disabled. + /// + /// @param other Source to copy from. + /// @return Reference to this source. + /// + /// @note Thread-safety: Not thread-safe. This operation is deleted. + PlatformAudioSource& operator=(const PlatformAudioSource& other) = delete; + + /// Move the platform audio source. + /// + /// @param other Source to move from. + /// + /// @note Thread-safety: Not thread-safe. The moved-from and moved-to objects + /// must not be accessed concurrently during the move. + PlatformAudioSource(PlatformAudioSource&& other) noexcept = default; + + /// Move-assign the platform audio source. + /// + /// @param other Source to move from. + /// @return Reference to this source. + /// + /// @note Thread-safety: Not thread-safe. The moved-from and moved-to objects + /// must not be accessed concurrently during the move. + PlatformAudioSource& operator=(PlatformAudioSource&& other) noexcept = default; + + /// Return the underlying FFI handle ID used in FFI requests. + /// + /// @note Thread-safety: Thread-safe. Reads immutable handle state. + std::uint64_t ffiHandleId() const noexcept { return static_cast(handle_.get()); } + +private: + friend class PlatformAudio; + + PlatformAudioSource(FfiHandle handle, std::shared_ptr platform_audio) noexcept; + + FfiHandle handle_; + std::shared_ptr platform_audio_; +}; + +/// Platform audio device manager backed by WebRTC's Audio Device Module. +/// +/// Use PlatformAudio for normal microphone publishing when built-in echo +/// cancellation, noise suppression, automatic gain control, and speaker playout +/// are desired. Use AudioSource instead when the application needs direct access +/// to raw PCM frames or custom audio generation. +/// +/// @note Thread-safety: Thread-safe. Methods send independent FFI requests and +/// share immutable handle state. +class LIVEKIT_API PlatformAudio { +public: + /// Create a platform audio manager. + /// + /// Enables WebRTC's platform Audio Device Module for microphone capture and + /// speaker playout. + /// + /// @throws PlatformAudioError If the FFI response is malformed or the + /// platform Audio Device Module cannot be created. + /// + /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + PlatformAudio(); + + /// Copy the platform audio manager. + /// + /// The copy shares the same underlying platform audio handle. + /// + /// @param other Manager to copy from. + /// + /// @note Thread-safety: Not thread-safe. The source object must not be + /// concurrently moved or assigned while copying. + PlatformAudio(const PlatformAudio& other) = default; + + /// Copy-assign the platform audio manager. + /// + /// The assigned instance shares the same underlying platform audio handle. + /// + /// @param other Manager to copy from. + /// @return Reference to this manager. + /// + /// @note Thread-safety: Not thread-safe. The assigned object and source + /// object must not be accessed concurrently during assignment. + PlatformAudio& operator=(const PlatformAudio& other) = default; + + /// Move the platform audio manager. + /// + /// @param other Manager to move from. + /// + /// @note Thread-safety: Not thread-safe. The moved-from and moved-to objects + /// must not be accessed concurrently during the move. + PlatformAudio(PlatformAudio&& other) noexcept = default; + + /// Move-assign the platform audio manager. + /// + /// @param other Manager to move from. + /// @return Reference to this manager. + /// + /// @note Thread-safety: Not thread-safe. The moved-from and moved-to objects + /// must not be accessed concurrently during the move. + PlatformAudio& operator=(PlatformAudio&& other) noexcept = default; + + /// Return the number of recording devices reported when this instance was created. + /// + /// @note Thread-safety: Thread-safe. Reads immutable handle state. + std::int32_t recordingDeviceCount() const noexcept; + + /// Return the number of playout devices reported when this instance was created. + /// + /// @note Thread-safety: Thread-safe. Reads immutable handle state. + std::int32_t playoutDeviceCount() const noexcept; + + /// Enumerate available microphones. + /// + /// @return List of available recording devices. + /// @throws PlatformAudioError If the FFI response is malformed or device + /// enumeration fails. + /// + /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + std::vector recordingDevices() const; + + /// Enumerate available speakers/headphones. + /// + /// @return List of available playout devices. + /// @throws PlatformAudioError If the FFI response is malformed or device + /// enumeration fails. + /// + /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + std::vector playoutDevices() const; + + /// Select the microphone by device ID. + /// + /// @param device_id Stable device identifier from AudioDeviceInfo::id. + /// @throws PlatformAudioError If the FFI response is malformed or device + /// selection fails. + /// + /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + void setRecordingDevice(const std::string& device_id) const; + + /// Select the speaker/headphones by device ID. + /// + /// @param device_id Stable device identifier from AudioDeviceInfo::id. + /// @throws PlatformAudioError If the FFI response is malformed or device + /// selection fails. + /// + /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + void setPlayoutDevice(const std::string& device_id) const; + + /// Create an automatically captured microphone source for LocalAudioTrack. + /// + /// @param options Audio processing options for the platform microphone path. + /// @return Platform-backed audio source suitable for LocalAudioTrack. + /// @throws PlatformAudioError If the FFI response is malformed or source + /// creation fails. + /// + /// @note Thread-safety: Thread-safe. Sends an independent FFI request and + /// returns a source that keeps the shared PlatformAudio state alive. + std::shared_ptr createAudioSource(const PlatformAudioOptions& options = {}) const; + +private: + std::shared_ptr state_; +}; + +} // namespace livekit diff --git a/src/local_audio_track.cpp b/src/local_audio_track.cpp index 3a2dd765..b20bc1a5 100644 --- a/src/local_audio_track.cpp +++ b/src/local_audio_track.cpp @@ -16,27 +16,58 @@ #include "livekit/local_audio_track.h" +#include +#include + #include "ffi.pb.h" #include "ffi_client.h" #include "livekit/audio_source.h" +#include "livekit/platform_audio.h" #include "track.pb.h" #include "track_proto_converter.h" namespace livekit { +namespace { + +proto::OwnedTrack createAudioTrackWithSourceHandle(const std::string& name, std::uint64_t source_handle) { + proto::FfiRequest req; + auto* msg = req.mutable_create_audio_track(); + msg->set_name(name); + msg->set_source_handle(source_handle); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_create_audio_track()) { + // TODO(sderosa): we dont have an error code/return, is throwing ok? + throw std::runtime_error("create_audio_track response is missing track"); + } + return resp.create_audio_track().track(); +} + +} // namespace + LocalAudioTrack::LocalAudioTrack(FfiHandle handle, const proto::OwnedTrack& track) : Track(std::move(handle), track.info().sid(), track.info().name(), fromProto(track.info().kind()), fromProto(track.info().stream_state()), track.info().muted(), false) {} std::shared_ptr LocalAudioTrack::createLocalAudioTrack(const std::string& name, const std::shared_ptr& source) { - proto::FfiRequest req; - auto* msg = req.mutable_create_audio_track(); - msg->set_name(name); - msg->set_source_handle(static_cast(source->ffiHandleId())); + if (!source) { + throw std::invalid_argument("LocalAudioTrack::createLocalAudioTrack: source is null"); + } - const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); - const proto::OwnedTrack& owned = resp.create_audio_track().track(); + const proto::OwnedTrack owned = createAudioTrackWithSourceHandle(name, source->ffiHandleId()); + FfiHandle handle(static_cast(owned.handle().id())); + return std::shared_ptr(new LocalAudioTrack(std::move(handle), owned)); +} + +std::shared_ptr LocalAudioTrack::createLocalAudioTrack( + const std::string& name, const std::shared_ptr& source) { + if (!source) { + throw std::invalid_argument("LocalAudioTrack::createLocalAudioTrack: source is null"); + } + + const proto::OwnedTrack owned = createAudioTrackWithSourceHandle(name, source->ffiHandleId()); FfiHandle handle(static_cast(owned.handle().id())); return std::shared_ptr(new LocalAudioTrack(std::move(handle), owned)); } @@ -73,4 +104,4 @@ void LocalAudioTrack::unmute() { std::string LocalAudioTrack::toString() const { return "rtc.LocalAudioTrack(sid=" + sid() + ", name=" + name() + ")"; } -} // namespace livekit \ No newline at end of file +} // namespace livekit diff --git a/src/platform_audio.cpp b/src/platform_audio.cpp new file mode 100644 index 00000000..bfd6b9da --- /dev/null +++ b/src/platform_audio.cpp @@ -0,0 +1,195 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "livekit/platform_audio.h" + +#include + +#include "ffi.pb.h" +#include "ffi_client.h" + +namespace livekit { + +struct PlatformAudioState { + FfiHandle handle; + std::int32_t recording_device_count = 0; + std::int32_t playout_device_count = 0; +}; + +namespace { + +std::uint64_t requireHandle(const std::shared_ptr& state) { + if (!state || !state->handle) { + throw PlatformAudioError("PlatformAudio has no valid FFI handle"); + } + return static_cast(state->handle.get()); +} + +AudioDeviceInfo fromProto(const proto::AudioDeviceInfo& device) { + AudioDeviceInfo out; + out.index = device.index(); + out.name = device.name(); + out.id = device.has_guid() ? device.guid() : std::string(); + return out; +} + +std::vector convertDevices(const google::protobuf::RepeatedPtrField& devices) { + std::vector out; + out.reserve(static_cast(devices.size())); + for (const auto& device : devices) { + out.push_back(fromProto(device)); + } + return out; +} + +proto::AudioSourceOptions toProto(const PlatformAudioOptions& options) { + proto::AudioSourceOptions out; + out.set_echo_cancellation(options.echo_cancellation); + out.set_noise_suppression(options.noise_suppression); + out.set_auto_gain_control(options.auto_gain_control); + out.set_prefer_hardware(options.prefer_hardware); + return out; +} + +} // namespace + +PlatformAudioSource::PlatformAudioSource(FfiHandle handle, std::shared_ptr platform_audio) noexcept + : handle_(std::move(handle)), platform_audio_(std::move(platform_audio)) {} + +PlatformAudio::PlatformAudio() { + proto::FfiRequest req; + req.mutable_new_platform_audio(); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_new_platform_audio()) { + throw PlatformAudioError("FfiResponse missing new_platform_audio"); + } + + const auto& platform_audio = resp.new_platform_audio(); + if (platform_audio.has_error()) { + throw PlatformAudioError(platform_audio.error()); + } + if (!platform_audio.has_platform_audio()) { + throw PlatformAudioError("NewPlatformAudioResponse missing platform_audio"); + } + + const auto& owned = platform_audio.platform_audio(); + state_ = std::make_shared(); + state_->handle = FfiHandle(static_cast(owned.handle().id())); + state_->recording_device_count = owned.info().recording_device_count(); + state_->playout_device_count = owned.info().playout_device_count(); +} + +std::int32_t PlatformAudio::recordingDeviceCount() const noexcept { + return state_ ? state_->recording_device_count : 0; +} + +std::int32_t PlatformAudio::playoutDeviceCount() const noexcept { return state_ ? state_->playout_device_count : 0; } + +std::vector PlatformAudio::recordingDevices() const { + proto::FfiRequest req; + req.mutable_get_audio_devices()->set_platform_audio_handle(requireHandle(state_)); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_get_audio_devices()) { + throw PlatformAudioError("FfiResponse missing get_audio_devices"); + } + + const auto& devices = resp.get_audio_devices(); + if (devices.has_error() && !devices.error().empty()) { + throw PlatformAudioError(devices.error()); + } + return convertDevices(devices.recording_devices()); +} + +std::vector PlatformAudio::playoutDevices() const { + proto::FfiRequest req; + req.mutable_get_audio_devices()->set_platform_audio_handle(requireHandle(state_)); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_get_audio_devices()) { + throw PlatformAudioError("FfiResponse missing get_audio_devices"); + } + + const auto& devices = resp.get_audio_devices(); + if (devices.has_error() && !devices.error().empty()) { + throw PlatformAudioError(devices.error()); + } + return convertDevices(devices.playout_devices()); +} + +void PlatformAudio::setRecordingDevice(const std::string& device_id) const { + proto::FfiRequest req; + auto* msg = req.mutable_set_recording_device(); + msg->set_platform_audio_handle(requireHandle(state_)); + msg->set_device_id(device_id); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_set_recording_device()) { + throw PlatformAudioError("FfiResponse missing set_recording_device"); + } + + const auto& set_device = resp.set_recording_device(); + if (set_device.has_error() && !set_device.error().empty()) { + throw PlatformAudioError(set_device.error()); + } +} + +void PlatformAudio::setPlayoutDevice(const std::string& device_id) const { + proto::FfiRequest req; + auto* msg = req.mutable_set_playout_device(); + msg->set_platform_audio_handle(requireHandle(state_)); + msg->set_device_id(device_id); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_set_playout_device()) { + throw PlatformAudioError("FfiResponse missing set_playout_device"); + } + + const auto& set_device = resp.set_playout_device(); + if (set_device.has_error() && !set_device.error().empty()) { + throw PlatformAudioError(set_device.error()); + } +} + +std::shared_ptr PlatformAudio::createAudioSource(const PlatformAudioOptions& options) const { + proto::FfiRequest req; + auto* msg = req.mutable_new_audio_source(); + msg->set_type(proto::AudioSourceType::AUDIO_SOURCE_PLATFORM); + msg->set_platform_audio_handle(requireHandle(state_)); + *msg->mutable_options() = toProto(options); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_new_audio_source()) { + throw PlatformAudioError("FfiResponse missing new_audio_source"); + } + + const auto& new_source = resp.new_audio_source(); + if (!new_source.has_source() || !new_source.source().has_handle()) { + throw PlatformAudioError("NewAudioSourceResponse missing source handle"); + } + + const auto& source = new_source.source(); + const std::uint64_t handle_id = source.handle().id(); + if (handle_id == 0) { + throw PlatformAudioError("NewAudioSourceResponse returned an invalid (null) source handle"); + } + + FfiHandle handle(static_cast(handle_id)); + return std::shared_ptr(new PlatformAudioSource(std::move(handle), state_)); +} + +} // namespace livekit diff --git a/src/tests/integration/test_platform_audio.cpp b/src/tests/integration/test_platform_audio.cpp new file mode 100644 index 00000000..40b14d53 --- /dev/null +++ b/src/tests/integration/test_platform_audio.cpp @@ -0,0 +1,239 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include +#include + +#include "../common/test_common.h" + +namespace livekit::test { + +using namespace std::chrono_literals; + +namespace { + +constexpr auto kSubscriptionTimeout = 20s; + +/// Tracks subscription/unpublish events seen by the receiver so tests can wait +/// for the platform-audio track to round-trip through the SFU. +struct PlatformTrackState { + std::mutex mutex; + std::condition_variable cv; + std::set subscribed_audio_names; + std::set unsubscribed_sids; + std::set unpublished_sids; +}; + +class PlatformTrackCollectorDelegate : public RoomDelegate { +public: + explicit PlatformTrackCollectorDelegate(PlatformTrackState& state) : state_(state) {} + + void onTrackSubscribed(Room&, const TrackSubscribedEvent& event) override { + std::lock_guard lock(state_.mutex); + if (event.track && event.track->kind() == TrackKind::KIND_AUDIO && event.publication) { + state_.subscribed_audio_names.insert(event.publication->name()); + } + state_.cv.notify_all(); + } + + void onTrackUnsubscribed(Room&, const TrackUnsubscribedEvent& event) override { + std::lock_guard lock(state_.mutex); + if (event.track) { + state_.unsubscribed_sids.insert(event.track->sid()); + } + state_.cv.notify_all(); + } + + void onTrackUnpublished(Room&, const TrackUnpublishedEvent& event) override { + std::lock_guard lock(state_.mutex); + if (event.publication) { + state_.unpublished_sids.insert(event.publication->sid()); + } + state_.cv.notify_all(); + } + +private: + PlatformTrackState& state_; +}; + +} // namespace + +class PlatformAudioIntegrationTest : public LiveKitTestBase {}; + +// Publishing a platform-ADM-backed audio track should reach a remote +// participant exactly like a manually fed AudioSource track. No real +// microphone is required: the source captures silence on headless runners, +// but the publish/subscribe round-trip still completes. +TEST_F(PlatformAudioIntegrationTest, PublishPlatformAudioTrackEndToEnd) { + if (!config_.available) { + throw std::runtime_error("LIVEKIT_URL, LIVEKIT_TOKEN_A, and LIVEKIT_TOKEN_B not set"); + } + + std::unique_ptr platform_audio; + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } + + RoomOptions options; + options.auto_subscribe = true; + + PlatformTrackState receiver_state; + PlatformTrackCollectorDelegate receiver_delegate(receiver_state); + + auto receiver_room = std::make_unique(); + receiver_room->setDelegate(&receiver_delegate); + ASSERT_TRUE(receiver_room->connect(config_.url, config_.token_b, options)) << "Receiver failed to connect"; + + auto sender_room = std::make_unique(); + ASSERT_TRUE(sender_room->connect(config_.url, config_.token_a, options)) << "Sender failed to connect"; + + const auto source = platform_audio->createAudioSource(); + ASSERT_NE(source, nullptr); + EXPECT_NE(source->ffiHandleId(), 0u); + + const std::string track_name = "platform-mic"; + const auto track = LocalAudioTrack::createLocalAudioTrack(track_name, source); + ASSERT_NE(track, nullptr); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_MICROPHONE; + lockLocalParticipant(*sender_room)->publishTrack(track, publish_options); + + std::unique_lock lock(receiver_state.mutex); + const bool subscribed = receiver_state.cv.wait_for( + lock, kSubscriptionTimeout, [&]() { return receiver_state.subscribed_audio_names.count(track_name) > 0; }); + EXPECT_TRUE(subscribed) << "Receiver never subscribed to the platform audio track"; +} + +// Unpublishing a platform audio track must propagate to the remote, exercising +// the source/track lifecycle that keeps the shared PlatformAudioState alive. +TEST_F(PlatformAudioIntegrationTest, UnpublishPlatformAudioTrackPropagates) { + if (!config_.available) { + throw std::runtime_error("LIVEKIT_URL, LIVEKIT_TOKEN_A, and LIVEKIT_TOKEN_B not set"); + } + + std::unique_ptr platform_audio; + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } + + RoomOptions options; + options.auto_subscribe = true; + + PlatformTrackState receiver_state; + PlatformTrackCollectorDelegate receiver_delegate(receiver_state); + + auto receiver_room = std::make_unique(); + receiver_room->setDelegate(&receiver_delegate); + ASSERT_TRUE(receiver_room->connect(config_.url, config_.token_b, options)) << "Receiver failed to connect"; + + auto sender_room = std::make_unique(); + ASSERT_TRUE(sender_room->connect(config_.url, config_.token_a, options)) << "Sender failed to connect"; + + const auto source = platform_audio->createAudioSource(); + ASSERT_NE(source, nullptr); + + const std::string track_name = "platform-mic-unpublish"; + const auto track = LocalAudioTrack::createLocalAudioTrack(track_name, source); + ASSERT_NE(track, nullptr); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_MICROPHONE; + lockLocalParticipant(*sender_room)->publishTrack(track, publish_options); + + { + std::unique_lock lock(receiver_state.mutex); + ASSERT_TRUE(receiver_state.cv.wait_for(lock, kSubscriptionTimeout, [&]() { + return receiver_state.subscribed_audio_names.count(track_name) > 0; + })) << "Receiver never subscribed to the platform audio track"; + } + + ASSERT_NE(track->publication(), nullptr); + const std::string published_sid = track->publication()->sid(); + lockLocalParticipant(*sender_room)->unpublishTrack(published_sid); + + std::unique_lock lock(receiver_state.mutex); + const bool removed = receiver_state.cv.wait_for(lock, kSubscriptionTimeout, [&]() { + return receiver_state.unpublished_sids.count(published_sid) > 0 || + receiver_state.unsubscribed_sids.count(published_sid) > 0; + }); + EXPECT_TRUE(removed) << "Receiver never saw the platform audio track removed"; +} + +// A single PlatformAudio manager can vend multiple independent sources, each +// with a distinct FFI handle, and both should publish end-to-end. +TEST_F(PlatformAudioIntegrationTest, MultipleSourcesFromOneManagerPublish) { + if (!config_.available) { + throw std::runtime_error("LIVEKIT_URL, LIVEKIT_TOKEN_A, and LIVEKIT_TOKEN_B not set"); + } + + std::unique_ptr platform_audio; + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } + + RoomOptions options; + options.auto_subscribe = true; + + PlatformTrackState receiver_state; + PlatformTrackCollectorDelegate receiver_delegate(receiver_state); + + auto receiver_room = std::make_unique(); + receiver_room->setDelegate(&receiver_delegate); + ASSERT_TRUE(receiver_room->connect(config_.url, config_.token_b, options)) << "Receiver failed to connect"; + + auto sender_room = std::make_unique(); + ASSERT_TRUE(sender_room->connect(config_.url, config_.token_a, options)) << "Sender failed to connect"; + + const auto source_a = platform_audio->createAudioSource(); + const auto source_b = platform_audio->createAudioSource(); + ASSERT_NE(source_a, nullptr); + ASSERT_NE(source_b, nullptr); + EXPECT_NE(source_a->ffiHandleId(), 0u); + EXPECT_NE(source_b->ffiHandleId(), 0u); + EXPECT_NE(source_a->ffiHandleId(), source_b->ffiHandleId()); + + const std::string name_a = "platform-mic-a"; + const std::string name_b = "platform-mic-b"; + const auto track_a = LocalAudioTrack::createLocalAudioTrack(name_a, source_a); + const auto track_b = LocalAudioTrack::createLocalAudioTrack(name_b, source_b); + ASSERT_NE(track_a, nullptr); + ASSERT_NE(track_b, nullptr); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_MICROPHONE; + lockLocalParticipant(*sender_room)->publishTrack(track_a, publish_options); + lockLocalParticipant(*sender_room)->publishTrack(track_b, publish_options); + + std::unique_lock lock(receiver_state.mutex); + const bool both_subscribed = receiver_state.cv.wait_for(lock, kSubscriptionTimeout, [&]() { + return receiver_state.subscribed_audio_names.count(name_a) > 0 && + receiver_state.subscribed_audio_names.count(name_b) > 0; + }); + EXPECT_TRUE(both_subscribed) << "Receiver did not subscribe to both platform audio tracks"; +} + +} // namespace livekit::test diff --git a/src/tests/unit/test_platform_audio.cpp b/src/tests/unit/test_platform_audio.cpp new file mode 100644 index 00000000..38a257c0 --- /dev/null +++ b/src/tests/unit/test_platform_audio.cpp @@ -0,0 +1,174 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include + +#include + +namespace livekit::test { + +class PlatformAudioTest : public ::testing::Test { +protected: + void SetUp() override { livekit::initialize(livekit::LogLevel::Info); } + void TearDown() override { livekit::shutdown(); } +}; + +TEST_F(PlatformAudioTest, DefaultOptionsEnableVoiceProcessing) { + PlatformAudioOptions options; + EXPECT_TRUE(options.echo_cancellation); + EXPECT_TRUE(options.noise_suppression); + EXPECT_TRUE(options.auto_gain_control); + EXPECT_FALSE(options.prefer_hardware); +} + +TEST_F(PlatformAudioTest, DeviceInfoStoresStableId) { + AudioDeviceInfo device; + device.index = 1; + device.name = "Microphone"; + device.id = "device-guid"; + + EXPECT_EQ(device.index, 1u); + EXPECT_EQ(device.name, "Microphone"); + EXPECT_EQ(device.id, "device-guid"); +} + +TEST_F(PlatformAudioTest, CreateSourceAndTrackWhenAvailable) { + std::unique_ptr platform_audio; + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } + + const auto source = platform_audio->createAudioSource(); + ASSERT_NE(source, nullptr); + EXPECT_NE(source->ffiHandleId(), 0u); + + const auto track = LocalAudioTrack::createLocalAudioTrack("platform-mic", source); + ASSERT_NE(track, nullptr); + EXPECT_EQ(track->name(), "platform-mic"); + EXPECT_EQ(track->kind(), TrackKind::KIND_AUDIO); +} + +TEST_F(PlatformAudioTest, MovedFromManagerThrowsOnUseButCountsAreSafe) { + std::unique_ptr platform_audio; + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } + + PlatformAudio moved_to = std::move(*platform_audio); + PlatformAudio& moved_from = *platform_audio; + + // The moved-to manager keeps the FFI handle and remains usable. + EXPECT_NO_THROW({ (void)moved_to.recordingDevices(); }); + + // The noexcept count accessors fall back to 0 on the emptied state. + EXPECT_EQ(moved_from.recordingDeviceCount(), 0); + EXPECT_EQ(moved_from.playoutDeviceCount(), 0); + + // Device operations on the emptied state must surface a clear error rather + // than dereferencing a null handle. + EXPECT_THROW((void)moved_from.recordingDevices(), PlatformAudioError); + EXPECT_THROW((void)moved_from.playoutDevices(), PlatformAudioError); + EXPECT_THROW(moved_from.setRecordingDevice("device-id"), PlatformAudioError); + EXPECT_THROW(moved_from.setPlayoutDevice("device-id"), PlatformAudioError); +} + +TEST_F(PlatformAudioTest, CopySharesHandleStateAndOutlivesOriginal) { + std::unique_ptr platform_audio; + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } + + // A copy shares the underlying handle, so the cached counts agree. + PlatformAudio copy = *platform_audio; + EXPECT_EQ(copy.recordingDeviceCount(), platform_audio->recordingDeviceCount()); + EXPECT_EQ(copy.playoutDeviceCount(), platform_audio->playoutDeviceCount()); + + // A source created from the copy keeps the shared state alive after the + // original manager is destroyed. + const auto source = copy.createAudioSource(); + ASSERT_NE(source, nullptr); + EXPECT_NE(source->ffiHandleId(), 0u); + + platform_audio.reset(); + EXPECT_NE(source->ffiHandleId(), 0u); +} + +TEST_F(PlatformAudioTest, CreateSourceWithCustomOptions) { + std::unique_ptr platform_audio; + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } + + PlatformAudioOptions options; + options.echo_cancellation = false; + options.noise_suppression = false; + options.auto_gain_control = false; + options.prefer_hardware = true; + + const auto source = platform_audio->createAudioSource(options); + ASSERT_NE(source, nullptr); + EXPECT_NE(source->ffiHandleId(), 0u); +} + +TEST_F(PlatformAudioTest, EnumerateDevicesAndSelectWhenAvailable) { + std::unique_ptr platform_audio; + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } + + // Enumeration must succeed even on headless runners (it may return empty). + std::vector recording_devices; + std::vector playout_devices; + EXPECT_NO_THROW({ recording_devices = platform_audio->recordingDevices(); }); + EXPECT_NO_THROW({ playout_devices = platform_audio->playoutDevices(); }); + + // Selecting a real device by its stable id must not throw. Headless runners + // usually report no devices, so guard the assertion behind availability. + bool selected_any = false; + for (const auto& device : recording_devices) { + if (!device.id.empty()) { + EXPECT_NO_THROW(platform_audio->setRecordingDevice(device.id)); + selected_any = true; + break; + } + } + for (const auto& device : playout_devices) { + if (!device.id.empty()) { + EXPECT_NO_THROW(platform_audio->setPlayoutDevice(device.id)); + selected_any = true; + break; + } + } + + if (!selected_any) { + GTEST_SKIP() << "No audio devices with stable ids available to select"; + } +} + +} // namespace livekit::test From 42ecc55376277295bad8c6240f89361931ab4910 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 2 Jun 2026 21:39:22 -0600 Subject: [PATCH 2/8] some mr comments --- include/livekit/local_audio_track.h | 14 ++-- include/livekit/platform_audio.h | 71 +++++-------------- src/tests/integration/test_platform_audio.cpp | 12 +--- 3 files changed, 27 insertions(+), 70 deletions(-) diff --git a/include/livekit/local_audio_track.h b/include/livekit/local_audio_track.h index 21c9deee..f0284920 100644 --- a/include/livekit/local_audio_track.h +++ b/include/livekit/local_audio_track.h @@ -55,8 +55,7 @@ class PlatformAudioSource; /// /// The track name provided during creation is visible to remote /// participants and can be used for debugging or UI display. -/// @note Thread-safety: Not thread-safe. This is a thin FFI wrapper with no -/// internal synchronization. +/// @note This operation is not thread-safe. class LIVEKIT_API LocalAudioTrack : public Track { public: /// Creates a new local audio track backed by the given `AudioSource`. @@ -69,7 +68,7 @@ class LIVEKIT_API LocalAudioTrack : public Track { /// /// @return A shared pointer to the newly constructed `LocalAudioTrack`. /// @throws std::invalid_argument If \p source is null. - /// @throws std::runtime_error If the FFI request fails. + /// @throws std::runtime_error If the FFI request fails. static std::shared_ptr createLocalAudioTrack(const std::string& name, const std::shared_ptr& source); @@ -82,10 +81,9 @@ class LIVEKIT_API LocalAudioTrack : public Track { /// /// @return A shared pointer to the newly constructed `LocalAudioTrack`. /// @throws std::invalid_argument If \p source is null. - /// @throws std::runtime_error If the FFI request fails. + /// @throws std::runtime_error If the FFI request fails. /// - /// @note Thread-safety: Not thread-safe. This is a thin FFI wrapper with no - /// internal synchronization. + /// @note This operation is not thread-safe. static std::shared_ptr createLocalAudioTrack(const std::string& name, const std::shared_ptr& source); @@ -94,14 +92,14 @@ class LIVEKIT_API LocalAudioTrack : public Track { /// A muted track stops sending audio to the room, but the track remains /// published and can be unmuted later without renegotiation. /// - /// @throws std::runtime_error If the FFI request fails. + /// @throws std::runtime_error If the FFI request fails. void mute(); /// Unmute the audio track. /// /// Resumes sending audio to the room. /// - /// @throws std::runtime_error If the FFI request fails. + /// @throws std::runtime_error If the FFI request fails. void unmute(); /// Return a human-readable string representation of the track. diff --git a/include/livekit/platform_audio.h b/include/livekit/platform_audio.h index b3ee0060..7bd3b34e 100644 --- a/include/livekit/platform_audio.h +++ b/include/livekit/platform_audio.h @@ -32,17 +32,13 @@ namespace livekit { /// This forward declaration is exposed only so public wrapper types can hold a /// shared implementation pointer. /// -/// @note Thread-safety: Thread-safe. Instances are managed internally by -/// PlatformAudio. +/// @note This object is thread-safe. struct PlatformAudioState; /// Information about a platform audio device. /// -/// Device indices may change when audio hardware is added or removed. Prefer +/// @note Device indices may change when audio hardware is added or removed. Use /// the stable `id` value when selecting a device. -/// -/// @note Thread-safety: Thread-safe. This is an aggregate value type with no -/// internal shared state. struct AudioDeviceInfo { /// Current device index. std::uint32_t index = 0; @@ -58,9 +54,6 @@ struct AudioDeviceInfo { /// /// The default values enable WebRTC's voice processing path for typical /// microphone publishing. -/// -/// @note Thread-safety: Thread-safe. This is an aggregate value type with no -/// internal shared state. struct PlatformAudioOptions { /// Enable acoustic echo cancellation. bool echo_cancellation = true; @@ -76,17 +69,11 @@ struct PlatformAudioOptions { }; /// Error raised when platform audio setup or device operations fail. -/// -/// @note Thread-safety: Thread-safe. Instances are immutable after -/// construction. class LIVEKIT_API PlatformAudioError : public std::runtime_error { public: /// Create a platform audio error. /// /// @param message Human-readable error message. - /// - /// @note Thread-safety: Thread-safe. Instances are immutable after - /// construction. explicit PlatformAudioError(const std::string& message) : std::runtime_error(message) {} }; @@ -94,32 +81,19 @@ class LIVEKIT_API PlatformAudioError : public std::runtime_error { /// /// A PlatformAudioSource captures microphone audio automatically. Unlike /// AudioSource, callers do not push frames with captureFrame(). -/// -/// @note Thread-safety: Thread-safe. The source owns an immutable FFI handle -/// and keeps the shared PlatformAudio state alive. class LIVEKIT_API PlatformAudioSource { public: /// Copy construction is disabled. - /// - /// @param other Source to copy from. - /// - /// @note Thread-safety: Not thread-safe. This operation is deleted. PlatformAudioSource(const PlatformAudioSource& other) = delete; /// Copy assignment is disabled. - /// - /// @param other Source to copy from. - /// @return Reference to this source. - /// - /// @note Thread-safety: Not thread-safe. This operation is deleted. PlatformAudioSource& operator=(const PlatformAudioSource& other) = delete; /// Move the platform audio source. /// /// @param other Source to move from. /// - /// @note Thread-safety: Not thread-safe. The moved-from and moved-to objects - /// must not be accessed concurrently during the move. + /// @note This operation is not thread-safe. PlatformAudioSource(PlatformAudioSource&& other) noexcept = default; /// Move-assign the platform audio source. @@ -127,13 +101,12 @@ class LIVEKIT_API PlatformAudioSource { /// @param other Source to move from. /// @return Reference to this source. /// - /// @note Thread-safety: Not thread-safe. The moved-from and moved-to objects - /// must not be accessed concurrently during the move. + /// @note This operation is not thread-safe. PlatformAudioSource& operator=(PlatformAudioSource&& other) noexcept = default; /// Return the underlying FFI handle ID used in FFI requests. /// - /// @note Thread-safety: Thread-safe. Reads immutable handle state. + /// @note This operation is thread-safe. std::uint64_t ffiHandleId() const noexcept { return static_cast(handle_.get()); } private: @@ -147,13 +120,10 @@ class LIVEKIT_API PlatformAudioSource { /// Platform audio device manager backed by WebRTC's Audio Device Module. /// -/// Use PlatformAudio for normal microphone publishing when built-in echo +/// Use PlatformAudio for microphone capture when built-in echo /// cancellation, noise suppression, automatic gain control, and speaker playout /// are desired. Use AudioSource instead when the application needs direct access /// to raw PCM frames or custom audio generation. -/// -/// @note Thread-safety: Thread-safe. Methods send independent FFI requests and -/// share immutable handle state. class LIVEKIT_API PlatformAudio { public: /// Create a platform audio manager. @@ -164,7 +134,7 @@ class LIVEKIT_API PlatformAudio { /// @throws PlatformAudioError If the FFI response is malformed or the /// platform Audio Device Module cannot be created. /// - /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + /// @note This operation is thread-safe. PlatformAudio(); /// Copy the platform audio manager. @@ -173,8 +143,7 @@ class LIVEKIT_API PlatformAudio { /// /// @param other Manager to copy from. /// - /// @note Thread-safety: Not thread-safe. The source object must not be - /// concurrently moved or assigned while copying. + /// @note This operation is not thread-safe. PlatformAudio(const PlatformAudio& other) = default; /// Copy-assign the platform audio manager. @@ -184,16 +153,14 @@ class LIVEKIT_API PlatformAudio { /// @param other Manager to copy from. /// @return Reference to this manager. /// - /// @note Thread-safety: Not thread-safe. The assigned object and source - /// object must not be accessed concurrently during assignment. + /// @note This operation is not thread-safe. PlatformAudio& operator=(const PlatformAudio& other) = default; /// Move the platform audio manager. /// /// @param other Manager to move from. /// - /// @note Thread-safety: Not thread-safe. The moved-from and moved-to objects - /// must not be accessed concurrently during the move. + /// @note This operation is not thread-safe. PlatformAudio(PlatformAudio&& other) noexcept = default; /// Move-assign the platform audio manager. @@ -201,18 +168,17 @@ class LIVEKIT_API PlatformAudio { /// @param other Manager to move from. /// @return Reference to this manager. /// - /// @note Thread-safety: Not thread-safe. The moved-from and moved-to objects - /// must not be accessed concurrently during the move. + /// @note This operation is not thread-safe. PlatformAudio& operator=(PlatformAudio&& other) noexcept = default; /// Return the number of recording devices reported when this instance was created. /// - /// @note Thread-safety: Thread-safe. Reads immutable handle state. + /// @note This operation is thread-safe. std::int32_t recordingDeviceCount() const noexcept; /// Return the number of playout devices reported when this instance was created. /// - /// @note Thread-safety: Thread-safe. Reads immutable handle state. + /// @note This operation is thread-safe. std::int32_t playoutDeviceCount() const noexcept; /// Enumerate available microphones. @@ -221,7 +187,7 @@ class LIVEKIT_API PlatformAudio { /// @throws PlatformAudioError If the FFI response is malformed or device /// enumeration fails. /// - /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + /// @note This operation is thread-safe. std::vector recordingDevices() const; /// Enumerate available speakers/headphones. @@ -230,7 +196,7 @@ class LIVEKIT_API PlatformAudio { /// @throws PlatformAudioError If the FFI response is malformed or device /// enumeration fails. /// - /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + /// @note This operation is thread-safe. std::vector playoutDevices() const; /// Select the microphone by device ID. @@ -239,7 +205,7 @@ class LIVEKIT_API PlatformAudio { /// @throws PlatformAudioError If the FFI response is malformed or device /// selection fails. /// - /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + /// @note This operation is thread-safe. void setRecordingDevice(const std::string& device_id) const; /// Select the speaker/headphones by device ID. @@ -248,7 +214,7 @@ class LIVEKIT_API PlatformAudio { /// @throws PlatformAudioError If the FFI response is malformed or device /// selection fails. /// - /// @note Thread-safety: Thread-safe. Sends an independent FFI request. + /// @note This operation is thread-safe. void setPlayoutDevice(const std::string& device_id) const; /// Create an automatically captured microphone source for LocalAudioTrack. @@ -258,8 +224,7 @@ class LIVEKIT_API PlatformAudio { /// @throws PlatformAudioError If the FFI response is malformed or source /// creation fails. /// - /// @note Thread-safety: Thread-safe. Sends an independent FFI request and - /// returns a source that keeps the shared PlatformAudio state alive. + /// @note This operation is thread-safe. std::shared_ptr createAudioSource(const PlatformAudioOptions& options = {}) const; private: diff --git a/src/tests/integration/test_platform_audio.cpp b/src/tests/integration/test_platform_audio.cpp index 40b14d53..edebb296 100644 --- a/src/tests/integration/test_platform_audio.cpp +++ b/src/tests/integration/test_platform_audio.cpp @@ -82,9 +82,7 @@ class PlatformAudioIntegrationTest : public LiveKitTestBase {}; // microphone is required: the source captures silence on headless runners, // but the publish/subscribe round-trip still completes. TEST_F(PlatformAudioIntegrationTest, PublishPlatformAudioTrackEndToEnd) { - if (!config_.available) { - throw std::runtime_error("LIVEKIT_URL, LIVEKIT_TOKEN_A, and LIVEKIT_TOKEN_B not set"); - } + EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; try { @@ -127,9 +125,7 @@ TEST_F(PlatformAudioIntegrationTest, PublishPlatformAudioTrackEndToEnd) { // Unpublishing a platform audio track must propagate to the remote, exercising // the source/track lifecycle that keeps the shared PlatformAudioState alive. TEST_F(PlatformAudioIntegrationTest, UnpublishPlatformAudioTrackPropagates) { - if (!config_.available) { - throw std::runtime_error("LIVEKIT_URL, LIVEKIT_TOKEN_A, and LIVEKIT_TOKEN_B not set"); - } + EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; try { @@ -184,9 +180,7 @@ TEST_F(PlatformAudioIntegrationTest, UnpublishPlatformAudioTrackPropagates) { // A single PlatformAudio manager can vend multiple independent sources, each // with a distinct FFI handle, and both should publish end-to-end. TEST_F(PlatformAudioIntegrationTest, MultipleSourcesFromOneManagerPublish) { - if (!config_.available) { - throw std::runtime_error("LIVEKIT_URL, LIVEKIT_TOKEN_A, and LIVEKIT_TOKEN_B not set"); - } + EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; try { From 5191befc3fa75b15a0927f9cfce83665b0732f47 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 2 Jun 2026 21:41:01 -0600 Subject: [PATCH 3/8] EXPECT_NO_THROW --- src/tests/integration/test_platform_audio.cpp | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/tests/integration/test_platform_audio.cpp b/src/tests/integration/test_platform_audio.cpp index edebb296..a5c28186 100644 --- a/src/tests/integration/test_platform_audio.cpp +++ b/src/tests/integration/test_platform_audio.cpp @@ -85,11 +85,7 @@ TEST_F(PlatformAudioIntegrationTest, PublishPlatformAudioTrackEndToEnd) { EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; - try { - platform_audio = std::make_unique(); - } catch (const PlatformAudioError& error) { - GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); - } + EXPECT_NO_THROW(platform_audio = std::make_unique()); RoomOptions options; options.auto_subscribe = true; @@ -128,11 +124,7 @@ TEST_F(PlatformAudioIntegrationTest, UnpublishPlatformAudioTrackPropagates) { EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; - try { - platform_audio = std::make_unique(); - } catch (const PlatformAudioError& error) { - GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); - } + EXPECT_NO_THROW(platform_audio = std::make_unique()); RoomOptions options; options.auto_subscribe = true; @@ -183,11 +175,7 @@ TEST_F(PlatformAudioIntegrationTest, MultipleSourcesFromOneManagerPublish) { EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; - try { - platform_audio = std::make_unique(); - } catch (const PlatformAudioError& error) { - GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); - } + EXPECT_NO_THROW(platform_audio = std::make_unique()); RoomOptions options; options.auto_subscribe = true; From bde192ee4175848a9531910d7bfdcbefd3321773 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 2 Jun 2026 21:49:16 -0600 Subject: [PATCH 4/8] rm todo --- src/local_audio_track.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/local_audio_track.cpp b/src/local_audio_track.cpp index b20bc1a5..eb00c955 100644 --- a/src/local_audio_track.cpp +++ b/src/local_audio_track.cpp @@ -38,7 +38,6 @@ proto::OwnedTrack createAudioTrackWithSourceHandle(const std::string& name, std: const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); if (!resp.has_create_audio_track()) { - // TODO(sderosa): we dont have an error code/return, is throwing ok? throw std::runtime_error("create_audio_track response is missing track"); } return resp.create_audio_track().track(); From 3e2540ff3c4281ceb954e916fce7921a387bbb10 Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 2 Jun 2026 22:09:47 -0600 Subject: [PATCH 5/8] comments --- src/platform_audio.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform_audio.cpp b/src/platform_audio.cpp index bfd6b9da..a9424d64 100644 --- a/src/platform_audio.cpp +++ b/src/platform_audio.cpp @@ -75,7 +75,7 @@ PlatformAudio::PlatformAudio() { const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); if (!resp.has_new_platform_audio()) { - throw PlatformAudioError("FfiResponse missing new_platform_audio"); + throw PlatformAudioError("Failed to construct PlatformAudio: FfiResponse missing new_platform_audio"); } const auto& platform_audio = resp.new_platform_audio(); @@ -83,7 +83,7 @@ PlatformAudio::PlatformAudio() { throw PlatformAudioError(platform_audio.error()); } if (!platform_audio.has_platform_audio()) { - throw PlatformAudioError("NewPlatformAudioResponse missing platform_audio"); + throw PlatformAudioError("Failed to construct PlatformAudio: NewPlatformAudioResponse missing platform_audio"); } const auto& owned = platform_audio.platform_audio(); From 83831b36c3f8411845aec952fa25f661f240b53f Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Tue, 2 Jun 2026 22:45:10 -0600 Subject: [PATCH 6/8] tests --- cpp-example-collection | 2 +- src/tests/integration/test_platform_audio.cpp | 77 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/cpp-example-collection b/cpp-example-collection index bbf0fdf7..628aceb6 160000 --- a/cpp-example-collection +++ b/cpp-example-collection @@ -1 +1 @@ -Subproject commit bbf0fdf72dac2239117213475449565686f8c58b +Subproject commit 628aceb6775ae7f8e8d7287731b771cf653ed87b diff --git a/src/tests/integration/test_platform_audio.cpp b/src/tests/integration/test_platform_audio.cpp index a5c28186..d04042f8 100644 --- a/src/tests/integration/test_platform_audio.cpp +++ b/src/tests/integration/test_platform_audio.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -218,4 +219,80 @@ TEST_F(PlatformAudioIntegrationTest, MultipleSourcesFromOneManagerPublish) { EXPECT_TRUE(both_subscribed) << "Receiver did not subscribe to both platform audio tracks"; } +// Audio captured by the platform Audio Device Module must actually stream to a +// remote participant as decoded frames, not merely produce a subscribed track. +// PlatformAudioSource captures the real microphone (silence on headless +// runners), so this verifies frames *flow* end-to-end without asserting on +// their content. +TEST_F(PlatformAudioIntegrationTest, PlatformAudioFramesReachRemote) { + EXPECT_TRUE(config_.available) << "Missing integration configuration"; + + std::unique_ptr platform_audio; + EXPECT_NO_THROW(platform_audio = std::make_unique()); + + RoomOptions options; + options.auto_subscribe = true; + + PlatformTrackState receiver_state; + PlatformTrackCollectorDelegate receiver_delegate(receiver_state); + + auto receiver_room = std::make_unique(); + receiver_room->setDelegate(&receiver_delegate); + ASSERT_TRUE(receiver_room->connect(config_.url, config_.token_b, options)) << "Receiver failed to connect"; + + auto sender_room = std::make_unique(); + ASSERT_TRUE(sender_room->connect(config_.url, config_.token_a, options)) << "Sender failed to connect"; + + const std::string sender_identity = lockLocalParticipant(*sender_room)->identity(); + + const auto source = platform_audio->createAudioSource(); + ASSERT_NE(source, nullptr); + + const std::string track_name = "platform-mic-frames"; + const auto track = LocalAudioTrack::createLocalAudioTrack(track_name, source); + ASSERT_NE(track, nullptr); + + // A few hundred ms of audio (10ms frames) is plenty to confirm the media path + // is live without making the test slow. + constexpr int kRequiredFrames = 10; + constexpr auto kFrameTimeout = 20s; + + std::mutex frame_mutex; + std::condition_variable frame_cv; + int received_frames = 0; + + // The reader thread is only started when the subscription event fires and a + // matching callback is already registered, so register before publishing. + receiver_room->setOnAudioFrameCallback(sender_identity, track_name, [&](const AudioFrame& frame) { + if (frame.totalSamples() == 0) { + return; + } + { + std::lock_guard lock(frame_mutex); + ++received_frames; + } + frame_cv.notify_all(); + }); + + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_MICROPHONE; + lockLocalParticipant(*sender_room)->publishTrack(track, publish_options); + + { + std::unique_lock lock(receiver_state.mutex); + ASSERT_TRUE(receiver_state.cv.wait_for(lock, kSubscriptionTimeout, [&]() { + return receiver_state.subscribed_audio_names.count(track_name) > 0; + })) << "Receiver never subscribed to the platform audio track"; + } + + bool frames_received = false; + { + std::unique_lock lock(frame_mutex); + frames_received = frame_cv.wait_for(lock, kFrameTimeout, [&]() { return received_frames >= kRequiredFrames; }); + } + EXPECT_TRUE(frames_received) << "Receiver did not get platform audio frames from the remote"; + + receiver_room->clearOnAudioFrameCallback(sender_identity, track_name); +} + } // namespace livekit::test From b445f9313a3d4a37c5332532341fa4066c3a0ebc Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 3 Jun 2026 10:12:01 -0600 Subject: [PATCH 7/8] expect no throw --- src/tests/unit/test_platform_audio.cpp | 30 +++++--------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/src/tests/unit/test_platform_audio.cpp b/src/tests/unit/test_platform_audio.cpp index 38a257c0..8a3fa425 100644 --- a/src/tests/unit/test_platform_audio.cpp +++ b/src/tests/unit/test_platform_audio.cpp @@ -50,11 +50,7 @@ TEST_F(PlatformAudioTest, DeviceInfoStoresStableId) { TEST_F(PlatformAudioTest, CreateSourceAndTrackWhenAvailable) { std::unique_ptr platform_audio; - try { - platform_audio = std::make_unique(); - } catch (const PlatformAudioError& error) { - GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); - } + EXPECT_NO_THROW(platform_audio = std::make_unique()); const auto source = platform_audio->createAudioSource(); ASSERT_NE(source, nullptr); @@ -68,11 +64,7 @@ TEST_F(PlatformAudioTest, CreateSourceAndTrackWhenAvailable) { TEST_F(PlatformAudioTest, MovedFromManagerThrowsOnUseButCountsAreSafe) { std::unique_ptr platform_audio; - try { - platform_audio = std::make_unique(); - } catch (const PlatformAudioError& error) { - GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); - } + EXPECT_NO_THROW(platform_audio = std::make_unique()); PlatformAudio moved_to = std::move(*platform_audio); PlatformAudio& moved_from = *platform_audio; @@ -94,11 +86,7 @@ TEST_F(PlatformAudioTest, MovedFromManagerThrowsOnUseButCountsAreSafe) { TEST_F(PlatformAudioTest, CopySharesHandleStateAndOutlivesOriginal) { std::unique_ptr platform_audio; - try { - platform_audio = std::make_unique(); - } catch (const PlatformAudioError& error) { - GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); - } + EXPECT_NO_THROW(platform_audio = std::make_unique()); // A copy shares the underlying handle, so the cached counts agree. PlatformAudio copy = *platform_audio; @@ -117,11 +105,7 @@ TEST_F(PlatformAudioTest, CopySharesHandleStateAndOutlivesOriginal) { TEST_F(PlatformAudioTest, CreateSourceWithCustomOptions) { std::unique_ptr platform_audio; - try { - platform_audio = std::make_unique(); - } catch (const PlatformAudioError& error) { - GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); - } + EXPECT_NO_THROW(platform_audio = std::make_unique()); PlatformAudioOptions options; options.echo_cancellation = false; @@ -136,11 +120,7 @@ TEST_F(PlatformAudioTest, CreateSourceWithCustomOptions) { TEST_F(PlatformAudioTest, EnumerateDevicesAndSelectWhenAvailable) { std::unique_ptr platform_audio; - try { - platform_audio = std::make_unique(); - } catch (const PlatformAudioError& error) { - GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); - } + EXPECT_NO_THROW(platform_audio = std::make_unique()); // Enumeration must succeed even on headless runners (it may return empty). std::vector recording_devices; From 97a4b6d65db0e3c8d670d3cf4b9eec9fca94b54a Mon Sep 17 00:00:00 2001 From: Stephen DeRosa Date: Wed, 3 Jun 2026 11:34:36 -0600 Subject: [PATCH 8/8] PlatformAudio requires audio device --- src/tests/integration/test_platform_audio.cpp | 38 ++++++++++++++----- src/tests/unit/test_platform_audio.cpp | 30 ++++++++++++--- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/tests/integration/test_platform_audio.cpp b/src/tests/integration/test_platform_audio.cpp index d04042f8..fe35c7b7 100644 --- a/src/tests/integration/test_platform_audio.cpp +++ b/src/tests/integration/test_platform_audio.cpp @@ -79,14 +79,19 @@ class PlatformTrackCollectorDelegate : public RoomDelegate { class PlatformAudioIntegrationTest : public LiveKitTestBase {}; // Publishing a platform-ADM-backed audio track should reach a remote -// participant exactly like a manually fed AudioSource track. No real -// microphone is required: the source captures silence on headless runners, -// but the publish/subscribe round-trip still completes. +// participant exactly like a manually fed AudioSource track. Unlike AudioSource, +// PlatformAudio requires a working platform Audio Device Module: constructing it +// throws PlatformAudioError when no ADM is available (e.g. a headless runner with +// no audio subsystem), so the test is skipped in that environment. TEST_F(PlatformAudioIntegrationTest, PublishPlatformAudioTrackEndToEnd) { EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; - EXPECT_NO_THROW(platform_audio = std::make_unique()); + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } RoomOptions options; options.auto_subscribe = true; @@ -125,7 +130,11 @@ TEST_F(PlatformAudioIntegrationTest, UnpublishPlatformAudioTrackPropagates) { EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; - EXPECT_NO_THROW(platform_audio = std::make_unique()); + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } RoomOptions options; options.auto_subscribe = true; @@ -176,7 +185,11 @@ TEST_F(PlatformAudioIntegrationTest, MultipleSourcesFromOneManagerPublish) { EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; - EXPECT_NO_THROW(platform_audio = std::make_unique()); + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } RoomOptions options; options.auto_subscribe = true; @@ -221,14 +234,19 @@ TEST_F(PlatformAudioIntegrationTest, MultipleSourcesFromOneManagerPublish) { // Audio captured by the platform Audio Device Module must actually stream to a // remote participant as decoded frames, not merely produce a subscribed track. -// PlatformAudioSource captures the real microphone (silence on headless -// runners), so this verifies frames *flow* end-to-end without asserting on -// their content. +// PlatformAudioSource captures the real microphone, so this verifies frames +// *flow* end-to-end without asserting on their content. PlatformAudio requires a +// working platform ADM, so the test is skipped when one is unavailable (e.g. a +// headless runner with no audio subsystem). TEST_F(PlatformAudioIntegrationTest, PlatformAudioFramesReachRemote) { EXPECT_TRUE(config_.available) << "Missing integration configuration"; std::unique_ptr platform_audio; - EXPECT_NO_THROW(platform_audio = std::make_unique()); + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } RoomOptions options; options.auto_subscribe = true; diff --git a/src/tests/unit/test_platform_audio.cpp b/src/tests/unit/test_platform_audio.cpp index 8a3fa425..38a257c0 100644 --- a/src/tests/unit/test_platform_audio.cpp +++ b/src/tests/unit/test_platform_audio.cpp @@ -50,7 +50,11 @@ TEST_F(PlatformAudioTest, DeviceInfoStoresStableId) { TEST_F(PlatformAudioTest, CreateSourceAndTrackWhenAvailable) { std::unique_ptr platform_audio; - EXPECT_NO_THROW(platform_audio = std::make_unique()); + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } const auto source = platform_audio->createAudioSource(); ASSERT_NE(source, nullptr); @@ -64,7 +68,11 @@ TEST_F(PlatformAudioTest, CreateSourceAndTrackWhenAvailable) { TEST_F(PlatformAudioTest, MovedFromManagerThrowsOnUseButCountsAreSafe) { std::unique_ptr platform_audio; - EXPECT_NO_THROW(platform_audio = std::make_unique()); + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } PlatformAudio moved_to = std::move(*platform_audio); PlatformAudio& moved_from = *platform_audio; @@ -86,7 +94,11 @@ TEST_F(PlatformAudioTest, MovedFromManagerThrowsOnUseButCountsAreSafe) { TEST_F(PlatformAudioTest, CopySharesHandleStateAndOutlivesOriginal) { std::unique_ptr platform_audio; - EXPECT_NO_THROW(platform_audio = std::make_unique()); + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } // A copy shares the underlying handle, so the cached counts agree. PlatformAudio copy = *platform_audio; @@ -105,7 +117,11 @@ TEST_F(PlatformAudioTest, CopySharesHandleStateAndOutlivesOriginal) { TEST_F(PlatformAudioTest, CreateSourceWithCustomOptions) { std::unique_ptr platform_audio; - EXPECT_NO_THROW(platform_audio = std::make_unique()); + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } PlatformAudioOptions options; options.echo_cancellation = false; @@ -120,7 +136,11 @@ TEST_F(PlatformAudioTest, CreateSourceWithCustomOptions) { TEST_F(PlatformAudioTest, EnumerateDevicesAndSelectWhenAvailable) { std::unique_ptr platform_audio; - EXPECT_NO_THROW(platform_audio = std::make_unique()); + try { + platform_audio = std::make_unique(); + } catch (const PlatformAudioError& error) { + GTEST_SKIP() << "PlatformAudio unavailable: " << error.what(); + } // Enumeration must succeed even on headless runners (it may return empty). std::vector recording_devices;