Skip to content

Commit e5572f5

Browse files
more unit and integration tests
1 parent e84617c commit e5572f5

2 files changed

Lines changed: 333 additions & 0 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*
2+
* Copyright 2026 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#include <chrono>
18+
#include <condition_variable>
19+
#include <memory>
20+
#include <mutex>
21+
#include <set>
22+
#include <string>
23+
24+
#include "../common/test_common.h"
25+
26+
namespace livekit::test {
27+
28+
using namespace std::chrono_literals;
29+
30+
namespace {
31+
32+
constexpr auto kSubscriptionTimeout = 20s;
33+
34+
/// Tracks subscription/unpublish events seen by the receiver so tests can wait
35+
/// for the platform-audio track to round-trip through the SFU.
36+
struct PlatformTrackState {
37+
std::mutex mutex;
38+
std::condition_variable cv;
39+
std::set<std::string> subscribed_audio_names;
40+
std::set<std::string> unsubscribed_sids;
41+
std::set<std::string> unpublished_sids;
42+
};
43+
44+
class PlatformTrackCollectorDelegate : public RoomDelegate {
45+
public:
46+
explicit PlatformTrackCollectorDelegate(PlatformTrackState& state) : state_(state) {}
47+
48+
void onTrackSubscribed(Room&, const TrackSubscribedEvent& event) override {
49+
std::lock_guard<std::mutex> lock(state_.mutex);
50+
if (event.track && event.track->kind() == TrackKind::KIND_AUDIO && event.publication) {
51+
state_.subscribed_audio_names.insert(event.publication->name());
52+
}
53+
state_.cv.notify_all();
54+
}
55+
56+
void onTrackUnsubscribed(Room&, const TrackUnsubscribedEvent& event) override {
57+
std::lock_guard<std::mutex> lock(state_.mutex);
58+
if (event.track) {
59+
state_.unsubscribed_sids.insert(event.track->sid());
60+
}
61+
state_.cv.notify_all();
62+
}
63+
64+
void onTrackUnpublished(Room&, const TrackUnpublishedEvent& event) override {
65+
std::lock_guard<std::mutex> lock(state_.mutex);
66+
if (event.publication) {
67+
state_.unpublished_sids.insert(event.publication->sid());
68+
}
69+
state_.cv.notify_all();
70+
}
71+
72+
private:
73+
PlatformTrackState& state_;
74+
};
75+
76+
/// Construct a PlatformAudio, or skip the calling test when the platform Audio
77+
/// Device Module is unavailable (headless CI runners have no audio hardware).
78+
std::unique_ptr<PlatformAudio> makePlatformAudioOrSkip() {
79+
try {
80+
return std::make_unique<PlatformAudio>();
81+
} catch (const PlatformAudioError& error) {
82+
GTEST_SKIP() << "PlatformAudio unavailable: " << error.what();
83+
}
84+
return nullptr;
85+
}
86+
87+
} // namespace
88+
89+
class PlatformAudioIntegrationTest : public LiveKitTestBase {};
90+
91+
// Publishing a platform-ADM-backed audio track should reach a remote
92+
// participant exactly like a manually fed AudioSource track. No real
93+
// microphone is required: the source captures silence on headless runners,
94+
// but the publish/subscribe round-trip still completes.
95+
TEST_F(PlatformAudioIntegrationTest, PublishPlatformAudioTrackEndToEnd) {
96+
skipIfNotConfigured();
97+
98+
auto platform_audio = makePlatformAudioOrSkip();
99+
ASSERT_NE(platform_audio, nullptr);
100+
101+
RoomOptions options;
102+
options.auto_subscribe = true;
103+
104+
PlatformTrackState receiver_state;
105+
PlatformTrackCollectorDelegate receiver_delegate(receiver_state);
106+
107+
auto receiver_room = std::make_unique<Room>();
108+
receiver_room->setDelegate(&receiver_delegate);
109+
ASSERT_TRUE(receiver_room->connect(config_.url, config_.token_b, options)) << "Receiver failed to connect";
110+
111+
auto sender_room = std::make_unique<Room>();
112+
ASSERT_TRUE(sender_room->connect(config_.url, config_.token_a, options)) << "Sender failed to connect";
113+
114+
const auto source = platform_audio->createAudioSource();
115+
ASSERT_NE(source, nullptr);
116+
EXPECT_NE(source->ffiHandleId(), 0u);
117+
118+
const std::string track_name = "platform-mic";
119+
const auto track = LocalAudioTrack::createLocalAudioTrack(track_name, source);
120+
ASSERT_NE(track, nullptr);
121+
122+
TrackPublishOptions publish_options;
123+
publish_options.source = TrackSource::SOURCE_MICROPHONE;
124+
lockLocalParticipant(*sender_room)->publishTrack(track, publish_options);
125+
126+
std::unique_lock<std::mutex> lock(receiver_state.mutex);
127+
const bool subscribed = receiver_state.cv.wait_for(
128+
lock, kSubscriptionTimeout, [&]() { return receiver_state.subscribed_audio_names.count(track_name) > 0; });
129+
EXPECT_TRUE(subscribed) << "Receiver never subscribed to the platform audio track";
130+
}
131+
132+
// Unpublishing a platform audio track must propagate to the remote, exercising
133+
// the source/track lifecycle that keeps the shared PlatformAudioState alive.
134+
TEST_F(PlatformAudioIntegrationTest, UnpublishPlatformAudioTrackPropagates) {
135+
skipIfNotConfigured();
136+
137+
auto platform_audio = makePlatformAudioOrSkip();
138+
ASSERT_NE(platform_audio, nullptr);
139+
140+
RoomOptions options;
141+
options.auto_subscribe = true;
142+
143+
PlatformTrackState receiver_state;
144+
PlatformTrackCollectorDelegate receiver_delegate(receiver_state);
145+
146+
auto receiver_room = std::make_unique<Room>();
147+
receiver_room->setDelegate(&receiver_delegate);
148+
ASSERT_TRUE(receiver_room->connect(config_.url, config_.token_b, options)) << "Receiver failed to connect";
149+
150+
auto sender_room = std::make_unique<Room>();
151+
ASSERT_TRUE(sender_room->connect(config_.url, config_.token_a, options)) << "Sender failed to connect";
152+
153+
const auto source = platform_audio->createAudioSource();
154+
ASSERT_NE(source, nullptr);
155+
156+
const std::string track_name = "platform-mic-unpublish";
157+
const auto track = LocalAudioTrack::createLocalAudioTrack(track_name, source);
158+
ASSERT_NE(track, nullptr);
159+
160+
TrackPublishOptions publish_options;
161+
publish_options.source = TrackSource::SOURCE_MICROPHONE;
162+
lockLocalParticipant(*sender_room)->publishTrack(track, publish_options);
163+
164+
{
165+
std::unique_lock<std::mutex> lock(receiver_state.mutex);
166+
ASSERT_TRUE(receiver_state.cv.wait_for(lock, kSubscriptionTimeout, [&]() {
167+
return receiver_state.subscribed_audio_names.count(track_name) > 0;
168+
})) << "Receiver never subscribed to the platform audio track";
169+
}
170+
171+
ASSERT_NE(track->publication(), nullptr);
172+
const std::string published_sid = track->publication()->sid();
173+
lockLocalParticipant(*sender_room)->unpublishTrack(published_sid);
174+
175+
std::unique_lock<std::mutex> lock(receiver_state.mutex);
176+
const bool removed = receiver_state.cv.wait_for(lock, kSubscriptionTimeout, [&]() {
177+
return receiver_state.unpublished_sids.count(published_sid) > 0 ||
178+
receiver_state.unsubscribed_sids.count(published_sid) > 0;
179+
});
180+
EXPECT_TRUE(removed) << "Receiver never saw the platform audio track removed";
181+
}
182+
183+
// A single PlatformAudio manager can vend multiple independent sources, each
184+
// with a distinct FFI handle, and both should publish end-to-end.
185+
TEST_F(PlatformAudioIntegrationTest, MultipleSourcesFromOneManagerPublish) {
186+
skipIfNotConfigured();
187+
188+
auto platform_audio = makePlatformAudioOrSkip();
189+
ASSERT_NE(platform_audio, nullptr);
190+
191+
RoomOptions options;
192+
options.auto_subscribe = true;
193+
194+
PlatformTrackState receiver_state;
195+
PlatformTrackCollectorDelegate receiver_delegate(receiver_state);
196+
197+
auto receiver_room = std::make_unique<Room>();
198+
receiver_room->setDelegate(&receiver_delegate);
199+
ASSERT_TRUE(receiver_room->connect(config_.url, config_.token_b, options)) << "Receiver failed to connect";
200+
201+
auto sender_room = std::make_unique<Room>();
202+
ASSERT_TRUE(sender_room->connect(config_.url, config_.token_a, options)) << "Sender failed to connect";
203+
204+
const auto source_a = platform_audio->createAudioSource();
205+
const auto source_b = platform_audio->createAudioSource();
206+
ASSERT_NE(source_a, nullptr);
207+
ASSERT_NE(source_b, nullptr);
208+
EXPECT_NE(source_a->ffiHandleId(), 0u);
209+
EXPECT_NE(source_b->ffiHandleId(), 0u);
210+
EXPECT_NE(source_a->ffiHandleId(), source_b->ffiHandleId());
211+
212+
const std::string name_a = "platform-mic-a";
213+
const std::string name_b = "platform-mic-b";
214+
const auto track_a = LocalAudioTrack::createLocalAudioTrack(name_a, source_a);
215+
const auto track_b = LocalAudioTrack::createLocalAudioTrack(name_b, source_b);
216+
ASSERT_NE(track_a, nullptr);
217+
ASSERT_NE(track_b, nullptr);
218+
219+
TrackPublishOptions publish_options;
220+
publish_options.source = TrackSource::SOURCE_MICROPHONE;
221+
lockLocalParticipant(*sender_room)->publishTrack(track_a, publish_options);
222+
lockLocalParticipant(*sender_room)->publishTrack(track_b, publish_options);
223+
224+
std::unique_lock<std::mutex> lock(receiver_state.mutex);
225+
const bool both_subscribed = receiver_state.cv.wait_for(lock, kSubscriptionTimeout, [&]() {
226+
return receiver_state.subscribed_audio_names.count(name_a) > 0 &&
227+
receiver_state.subscribed_audio_names.count(name_b) > 0;
228+
});
229+
EXPECT_TRUE(both_subscribed) << "Receiver did not subscribe to both platform audio tracks";
230+
}
231+
232+
} // namespace livekit::test

src/tests/unit/test_platform_audio.cpp

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ class PlatformAudioTest : public ::testing::Test {
2727
protected:
2828
void SetUp() override { livekit::initialize(livekit::LogLevel::Info); }
2929
void TearDown() override { livekit::shutdown(); }
30+
31+
/// Construct a PlatformAudio, or skip the calling test when the platform
32+
/// Audio Device Module is unavailable (headless CI runners have no audio
33+
/// hardware). Returns nullptr after recording the skip.
34+
static std::unique_ptr<PlatformAudio> makePlatformAudioOrSkip() {
35+
try {
36+
return std::make_unique<PlatformAudio>();
37+
} catch (const PlatformAudioError& error) {
38+
GTEST_SKIP() << "PlatformAudio unavailable: " << error.what();
39+
}
40+
return nullptr;
41+
}
3042
};
3143

3244
TEST_F(PlatformAudioTest, DefaultOptionsEnableVoiceProcessing) {
@@ -66,4 +78,93 @@ TEST_F(PlatformAudioTest, CreateSourceAndTrackWhenAvailable) {
6678
EXPECT_EQ(track->kind(), TrackKind::KIND_AUDIO);
6779
}
6880

81+
TEST_F(PlatformAudioTest, MovedFromManagerThrowsOnUseButCountsAreSafe) {
82+
auto platform_audio = makePlatformAudioOrSkip();
83+
ASSERT_NE(platform_audio, nullptr);
84+
85+
PlatformAudio moved_to = std::move(*platform_audio);
86+
PlatformAudio& moved_from = *platform_audio;
87+
88+
// The moved-to manager keeps the FFI handle and remains usable.
89+
EXPECT_NO_THROW({ (void)moved_to.recordingDevices(); });
90+
91+
// The noexcept count accessors fall back to 0 on the emptied state.
92+
EXPECT_EQ(moved_from.recordingDeviceCount(), 0);
93+
EXPECT_EQ(moved_from.playoutDeviceCount(), 0);
94+
95+
// Device operations on the emptied state must surface a clear error rather
96+
// than dereferencing a null handle.
97+
EXPECT_THROW((void)moved_from.recordingDevices(), PlatformAudioError);
98+
EXPECT_THROW((void)moved_from.playoutDevices(), PlatformAudioError);
99+
EXPECT_THROW(moved_from.setRecordingDevice("device-id"), PlatformAudioError);
100+
EXPECT_THROW(moved_from.setPlayoutDevice("device-id"), PlatformAudioError);
101+
}
102+
103+
TEST_F(PlatformAudioTest, CopySharesHandleStateAndOutlivesOriginal) {
104+
auto platform_audio = makePlatformAudioOrSkip();
105+
ASSERT_NE(platform_audio, nullptr);
106+
107+
// A copy shares the underlying handle, so the cached counts agree.
108+
PlatformAudio copy = *platform_audio;
109+
EXPECT_EQ(copy.recordingDeviceCount(), platform_audio->recordingDeviceCount());
110+
EXPECT_EQ(copy.playoutDeviceCount(), platform_audio->playoutDeviceCount());
111+
112+
// A source created from the copy keeps the shared state alive after the
113+
// original manager is destroyed.
114+
const auto source = copy.createAudioSource();
115+
ASSERT_NE(source, nullptr);
116+
EXPECT_NE(source->ffiHandleId(), 0u);
117+
118+
platform_audio.reset();
119+
EXPECT_NE(source->ffiHandleId(), 0u);
120+
}
121+
122+
TEST_F(PlatformAudioTest, CreateSourceWithCustomOptions) {
123+
auto platform_audio = makePlatformAudioOrSkip();
124+
ASSERT_NE(platform_audio, nullptr);
125+
126+
PlatformAudioOptions options;
127+
options.echo_cancellation = false;
128+
options.noise_suppression = false;
129+
options.auto_gain_control = false;
130+
options.prefer_hardware = true;
131+
132+
const auto source = platform_audio->createAudioSource(options);
133+
ASSERT_NE(source, nullptr);
134+
EXPECT_NE(source->ffiHandleId(), 0u);
135+
}
136+
137+
TEST_F(PlatformAudioTest, EnumerateDevicesAndSelectWhenAvailable) {
138+
auto platform_audio = makePlatformAudioOrSkip();
139+
ASSERT_NE(platform_audio, nullptr);
140+
141+
// Enumeration must succeed even on headless runners (it may return empty).
142+
std::vector<AudioDeviceInfo> recording_devices;
143+
std::vector<AudioDeviceInfo> playout_devices;
144+
EXPECT_NO_THROW({ recording_devices = platform_audio->recordingDevices(); });
145+
EXPECT_NO_THROW({ playout_devices = platform_audio->playoutDevices(); });
146+
147+
// Selecting a real device by its stable id must not throw. Headless runners
148+
// usually report no devices, so guard the assertion behind availability.
149+
bool selected_any = false;
150+
for (const auto& device : recording_devices) {
151+
if (!device.id.empty()) {
152+
EXPECT_NO_THROW(platform_audio->setRecordingDevice(device.id));
153+
selected_any = true;
154+
break;
155+
}
156+
}
157+
for (const auto& device : playout_devices) {
158+
if (!device.id.empty()) {
159+
EXPECT_NO_THROW(platform_audio->setPlayoutDevice(device.id));
160+
selected_any = true;
161+
break;
162+
}
163+
}
164+
165+
if (!selected_any) {
166+
GTEST_SKIP() << "No audio devices with stable ids available to select";
167+
}
168+
}
169+
69170
} // namespace livekit::test

0 commit comments

Comments
 (0)