diff --git a/sdk/BUILD.gn b/sdk/BUILD.gn index e7ec3e3614..f099ac28ac 100644 --- a/sdk/BUILD.gn +++ b/sdk/BUILD.gn @@ -100,6 +100,7 @@ if (is_ios || is_mac) { "objc/base/RTCLogging.h", "objc/base/RTCLogging.mm", "objc/base/RTCMacros.h", + "objc/base/RTCAudioRenderer.h", "objc/base/RTCMutableI420Buffer.h", "objc/base/RTCMutableYUVPlanarBuffer.h", "objc/base/RTCSSLCertificateVerifier.h", @@ -864,6 +865,24 @@ if (is_ios || is_mac) { ] } + rtc_library("audiorendereradapter_objc") { + visibility = [ "*" ] + allow_poison = [ "audio_codecs" ] # TODO(bugs.webrtc.org/8396): Remove. + sources = [ + "objc/api/RTCAudioRendererAdapter+Private.h", + "objc/api/RTCAudioRendererAdapter.h", + "objc/api/RTCAudioRendererAdapter.mm", + ] + + configs += [ "..:common_objc" ] + public_configs = [ ":common_config_objc" ] + + deps = [ + ":base_objc", + "../api:media_stream_interface", + ] + } + rtc_library("mediasource_objc") { sources = [ "objc/api/peerconnection/RTCMediaSource+Private.h", @@ -1041,6 +1060,7 @@ if (is_ios || is_mac) { public_configs = [ ":common_config_objc" ] deps = [ + ":audiorendereradapter_objc", ":audio_device_api_objc", ":base_native_additions_objc", ":base_objc", @@ -1117,6 +1137,7 @@ if (is_ios || is_mac) { "objc/unittests/RTCAudioDeviceModule_xctest.mm", "objc/unittests/RTCAudioDevice_xctest.mm", "objc/unittests/RTCAudioSessionTest.mm", + "objc/unittests/RTCAudioTrack_xctest.mm", "objc/unittests/RTCCVPixelBuffer_xctest.mm", "objc/unittests/RTCCallbackLogger_xctest.m", "objc/unittests/RTCCameraVideoCapturerTests.mm", @@ -1261,6 +1282,7 @@ if (is_ios || is_mac) { "objc/base/RTCI420Buffer.h", "objc/base/RTCLogging.h", "objc/base/RTCMacros.h", + "objc/base/RTCAudioRenderer.h", "objc/base/RTCMutableI420Buffer.h", "objc/base/RTCMutableYUVPlanarBuffer.h", "objc/base/RTCSSLCertificateVerifier.h", @@ -1465,6 +1487,7 @@ if (is_ios || is_mac) { "objc/base/RTCI420Buffer.h", "objc/base/RTCLogging.h", "objc/base/RTCMacros.h", + "objc/base/RTCAudioRenderer.h", "objc/base/RTCMutableI420Buffer.h", "objc/base/RTCMutableYUVPlanarBuffer.h", "objc/base/RTCSSLCertificateVerifier.h", diff --git a/sdk/objc/api/RTCAudioRendererAdapter+Private.h b/sdk/objc/api/RTCAudioRendererAdapter+Private.h new file mode 100644 index 0000000000..5a726e62e1 --- /dev/null +++ b/sdk/objc/api/RTCAudioRendererAdapter+Private.h @@ -0,0 +1,40 @@ +/* + * Copyright 2026 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +#import "RTCAudioRendererAdapter.h" + +#import "base/RTCAudioRenderer.h" + +#include "api/media_stream_interface.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface RTCAudioRendererAdapter () + +/** + * The Objective-C audio renderer passed to this adapter during construction. + * Calls made to the native AudioTrackSinkInterface will be adapted and passed + * to this renderer. + */ +@property(nonatomic, readonly) id audioRenderer; + +/** + * The native AudioTrackSinkInterface surface exposed by this adapter. This + * pointer is unsafe and owned by this class. + */ +@property(nonatomic, readonly) webrtc::AudioTrackSinkInterface *nativeAudioRenderer; + +/** Initialize an RTCAudioRendererAdapter with an RTCAudioRenderer. */ +- (instancetype)initWithNativeRenderer:(id)audioRenderer + NS_DESIGNATED_INITIALIZER; + +@end + +NS_ASSUME_NONNULL_END diff --git a/sdk/objc/api/RTCAudioRendererAdapter.h b/sdk/objc/api/RTCAudioRendererAdapter.h new file mode 100644 index 0000000000..cff8d8cfd7 --- /dev/null +++ b/sdk/objc/api/RTCAudioRendererAdapter.h @@ -0,0 +1,26 @@ +/* + * Copyright 2026 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/* + * Creates a webrtc::AudioTrackSinkInterface surface for an RTCAudioRenderer. + * The sink is used by WebRTC audio rendering code - this adapter forwards + * callbacks to the RTCAudioRenderer supplied during construction. + */ +@interface RTCAudioRendererAdapter : NSObject + +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/sdk/objc/api/RTCAudioRendererAdapter.mm b/sdk/objc/api/RTCAudioRendererAdapter.mm new file mode 100644 index 0000000000..1bca08a852 --- /dev/null +++ b/sdk/objc/api/RTCAudioRendererAdapter.mm @@ -0,0 +1,76 @@ +/* + * Copyright 2026 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +#import "RTCAudioRendererAdapter+Private.h" + +#include + +namespace webrtc { + +class AudioRendererAdapter : public webrtc::AudioTrackSinkInterface { + public: + explicit AudioRendererAdapter(::RTCAudioRendererAdapter* adapter) + : adapter_(adapter) {} + + void OnData(const void* audio_data, + int bits_per_sample, + int sample_rate, + size_t number_of_channels, + size_t number_of_frames) override { + [adapter_.audioRenderer renderPCMData:audio_data + bitsPerSample:bits_per_sample + sampleRate:sample_rate + numberOfChannels:number_of_channels + numberOfFrames:number_of_frames + absoluteCaptureTimestampMs:0 + timestampIsValid:NO]; + } + + void OnData(const void* audio_data, + int bits_per_sample, + int sample_rate, + size_t number_of_channels, + size_t number_of_frames, + absl::optional absolute_capture_timestamp_ms) override { + [adapter_.audioRenderer renderPCMData:audio_data + bitsPerSample:bits_per_sample + sampleRate:sample_rate + numberOfChannels:number_of_channels + numberOfFrames:number_of_frames + absoluteCaptureTimestampMs:absolute_capture_timestamp_ms.value_or(0) + timestampIsValid:absolute_capture_timestamp_ms.has_value()]; + } + + private: + __weak ::RTCAudioRendererAdapter* adapter_; +}; + +} // namespace webrtc + +@implementation RTCAudioRendererAdapter { + std::unique_ptr _adapter; +} + +@synthesize audioRenderer = _audioRenderer; + +- (instancetype)initWithNativeRenderer:(id)audioRenderer { + NSParameterAssert(audioRenderer); + if (self = [super init]) { + _audioRenderer = audioRenderer; + _adapter = std::make_unique(self); + } + return self; +} + +- (webrtc::AudioTrackSinkInterface*)nativeAudioRenderer { + return _adapter.get(); +} + +@end diff --git a/sdk/objc/api/peerconnection/RTCAudioTrack.h b/sdk/objc/api/peerconnection/RTCAudioTrack.h index 95eb5d3d48..36366a45bf 100644 --- a/sdk/objc/api/peerconnection/RTCAudioTrack.h +++ b/sdk/objc/api/peerconnection/RTCAudioTrack.h @@ -8,6 +8,9 @@ * be found in the AUTHORS file in the root of the source tree. */ +#import + +#import "RTCAudioRenderer.h" #import "RTCMacros.h" #import "RTCMediaStreamTrack.h" @@ -21,7 +24,13 @@ RTC_OBJC_EXPORT - (instancetype)init NS_UNAVAILABLE; /** The audio source for this audio track. */ -@property(nonatomic, readonly) RTC_OBJC_TYPE(RTCAudioSource) * source; +@property(nonatomic, readonly) RTC_OBJC_TYPE(RTCAudioSource) *source; + +/** Register a renderer that will receive decoded PCM frames on this track. */ +- (void)addRenderer:(id)renderer; + +/** Deregister a previously registered renderer. */ +- (void)removeRenderer:(id)renderer; @end diff --git a/sdk/objc/api/peerconnection/RTCAudioTrack.mm b/sdk/objc/api/peerconnection/RTCAudioTrack.mm index 5c1736f436..6192c45e75 100644 --- a/sdk/objc/api/peerconnection/RTCAudioTrack.mm +++ b/sdk/objc/api/peerconnection/RTCAudioTrack.mm @@ -10,14 +10,19 @@ #import "RTCAudioTrack+Private.h" +#import "api/RTCAudioRendererAdapter+Private.h" #import "RTCAudioSource+Private.h" #import "RTCMediaStreamTrack+Private.h" #import "RTCPeerConnectionFactory+Private.h" #import "helpers/NSString+StdString.h" #include "rtc_base/checks.h" +#include "rtc_base/logging.h" -@implementation RTC_OBJC_TYPE (RTCAudioTrack) +@implementation RTC_OBJC_TYPE (RTCAudioTrack) { + rtc::Thread *_workerThread; + NSMutableArray *_adapters /* accessed on _workerThread */; +} @synthesize source = _source; @@ -43,7 +48,17 @@ - (instancetype)initWithFactory:(RTC_OBJC_TYPE(RTCPeerConnectionFactory) *)facto NSParameterAssert(factory); NSParameterAssert(nativeTrack); NSParameterAssert(type == RTCMediaStreamTrackTypeAudio); - return [super initWithFactory:factory nativeTrack:nativeTrack type:type]; + if (self = [super initWithFactory:factory nativeTrack:nativeTrack type:type]) { + _adapters = [NSMutableArray array]; + _workerThread = factory.workerThread; + } + return self; +} + +- (void)dealloc { + for (RTCAudioRendererAdapter *adapter in _adapters) { + self.nativeAudioTrack->RemoveSink(adapter.nativeAudioRenderer); + } } - (RTC_OBJC_TYPE(RTCAudioSource) *)source { @@ -57,6 +72,50 @@ - (instancetype)initWithFactory:(RTC_OBJC_TYPE(RTCPeerConnectionFactory) *)facto return _source; } +- (void)addRenderer:(id)renderer { + if (!_workerThread->IsCurrent()) { + _workerThread->BlockingCall([renderer, self] { [self addRenderer:renderer]; }); + return; + } + + for (RTCAudioRendererAdapter *adapter in _adapters) { + if (adapter.audioRenderer == renderer) { + RTC_LOG(LS_INFO) << "|renderer| is already attached to this track"; + return; + } + } + + RTCAudioRendererAdapter *adapter = + [[RTCAudioRendererAdapter alloc] initWithNativeRenderer:renderer]; + [_adapters addObject:adapter]; + self.nativeAudioTrack->AddSink(adapter.nativeAudioRenderer); +} + +- (void)removeRenderer:(id)renderer { + if (!_workerThread->IsCurrent()) { + _workerThread->BlockingCall([renderer, self] { [self removeRenderer:renderer]; }); + return; + } + + __block NSUInteger indexToRemove = NSNotFound; + [_adapters enumerateObjectsUsingBlock:^(RTCAudioRendererAdapter *adapter, + NSUInteger idx, + BOOL *stop) { + if (adapter.audioRenderer == renderer) { + indexToRemove = idx; + *stop = YES; + } + }]; + if (indexToRemove == NSNotFound) { + RTC_LOG(LS_INFO) << "removeRenderer called with a renderer that has not been previously added"; + return; + } + + RTCAudioRendererAdapter *adapterToRemove = [_adapters objectAtIndex:indexToRemove]; + self.nativeAudioTrack->RemoveSink(adapterToRemove.nativeAudioRenderer); + [_adapters removeObjectAtIndex:indexToRemove]; +} + #pragma mark - Private - (rtc::scoped_refptr)nativeAudioTrack { diff --git a/sdk/objc/base/RTCAudioRenderer.h b/sdk/objc/base/RTCAudioRenderer.h new file mode 100644 index 0000000000..9a66ac6cb1 --- /dev/null +++ b/sdk/objc/base/RTCAudioRenderer.h @@ -0,0 +1,38 @@ +/* + * Copyright 2026 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +#import +#include +#include + +#import "RTCMacros.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + * Receives decoded PCM samples from an RTCAudioTrack. + * + * The audio_data pointer is only valid for the lifetime of the callback. + * Callbacks may be delivered on a WebRTC audio thread. + */ +RTC_OBJC_EXPORT +@protocol RTC_OBJC_TYPE(RTCAudioRenderer) + +- (void)renderPCMData:(const void *)audio_data + bitsPerSample:(int)bits_per_sample + sampleRate:(int)sample_rate + numberOfChannels:(size_t)number_of_channels + numberOfFrames:(size_t)number_of_frames +absoluteCaptureTimestampMs:(int64_t)absolute_capture_timestamp_ms + timestampIsValid:(BOOL)timestamp_is_valid; + +@end + +NS_ASSUME_NONNULL_END diff --git a/sdk/objc/unittests/RTCAudioTrack_xctest.mm b/sdk/objc/unittests/RTCAudioTrack_xctest.mm new file mode 100644 index 0000000000..52c4766452 --- /dev/null +++ b/sdk/objc/unittests/RTCAudioTrack_xctest.mm @@ -0,0 +1,258 @@ +/* + * Copyright 2026 The WebRTC project authors. All Rights Reserved. + * + * Use of this source code is governed by a BSD-style license + * that can be found in the LICENSE file in the root of the source + * tree. An additional intellectual property rights grant can be found + * in the file PATENTS. All contributing project authors may + * be found in the AUTHORS file in the root of the source tree. + */ + +#import +#import + +#include +#include +#include + +#include "absl/types/optional.h" +#include "api/media_stream_interface.h" +#include "api/scoped_refptr.h" +#include "rtc_base/gunit.h" +#include "rtc_base/ref_counted_object.h" + +#import "api/peerconnection/RTCAudioTrack.h" +#import "api/peerconnection/RTCMediaStreamTrack+Private.h" +#import "api/peerconnection/RTCPeerConnectionFactory.h" + +namespace { + +class FakeAudioTrack : public webrtc::AudioTrackInterface { + public: + explicit FakeAudioTrack(std::string track_id) + : track_id_(std::move(track_id)), enabled_(true), state_(kLive) {} + + std::string kind() const override { return kAudioKind; } + std::string id() const override { return track_id_; } + + bool enabled() const override { return enabled_; } + bool set_enabled(bool enable) override { + enabled_ = enable; + return true; + } + + TrackState state() const override { return state_; } + + webrtc::AudioSourceInterface* GetSource() const override { return nullptr; } + + void AddSink(webrtc::AudioTrackSinkInterface* sink) override { + ++add_sink_call_count_; + sinks_.push_back(sink); + } + + void RemoveSink(webrtc::AudioTrackSinkInterface* sink) override { + ++remove_sink_call_count_; + sinks_.erase(std::remove(sinks_.begin(), sinks_.end(), sink), sinks_.end()); + } + + bool GetSignalLevel(int* level) override { + if (level) { + *level = 0; + } + return true; + } + + rtc::scoped_refptr GetAudioProcessor() override { + return nullptr; + } + + void RegisterObserver(webrtc::ObserverInterface* observer) override {} + void UnregisterObserver(webrtc::ObserverInterface* observer) override {} + + size_t sink_count() const { return sinks_.size(); } + size_t add_sink_call_count() const { return add_sink_call_count_; } + size_t remove_sink_call_count() const { return remove_sink_call_count_; } + + void DeliverData(const void* audio_data, + int bits_per_sample, + int sample_rate, + size_t number_of_channels, + size_t number_of_frames, + absl::optional absolute_capture_timestamp_ms) { + auto sinks_snapshot = sinks_; + for (webrtc::AudioTrackSinkInterface* sink : sinks_snapshot) { + if (absolute_capture_timestamp_ms.has_value()) { + sink->OnData(audio_data, bits_per_sample, sample_rate, number_of_channels, + number_of_frames, absolute_capture_timestamp_ms); + } else { + sink->OnData(audio_data, bits_per_sample, sample_rate, number_of_channels, + number_of_frames); + } + } + } + + protected: + ~FakeAudioTrack() override = default; + + private: + std::string track_id_; + bool enabled_; + TrackState state_; + size_t add_sink_call_count_ = 0; + size_t remove_sink_call_count_ = 0; + std::vector sinks_; +}; + +} // namespace + +@interface RTCAudioTrackTestRenderer : NSObject +@property(nonatomic, assign) NSInteger callbackCount; +@property(nonatomic, assign) const void *lastAudioData; +@property(nonatomic, assign) int lastBitsPerSample; +@property(nonatomic, assign) int lastSampleRate; +@property(nonatomic, assign) size_t lastNumberOfChannels; +@property(nonatomic, assign) size_t lastNumberOfFrames; +@property(nonatomic, assign) int64_t lastAbsoluteCaptureTimestampMs; +@property(nonatomic, assign) BOOL lastTimestampIsValid; +@end + +@implementation RTCAudioTrackTestRenderer + +- (void)renderPCMData:(const void *)audio_data + bitsPerSample:(int)bits_per_sample + sampleRate:(int)sample_rate + numberOfChannels:(size_t)number_of_channels + numberOfFrames:(size_t)number_of_frames +absoluteCaptureTimestampMs:(int64_t)absolute_capture_timestamp_ms + timestampIsValid:(BOOL)timestamp_is_valid { + self.callbackCount += 1; + self.lastAudioData = audio_data; + self.lastBitsPerSample = bits_per_sample; + self.lastSampleRate = sample_rate; + self.lastNumberOfChannels = number_of_channels; + self.lastNumberOfFrames = number_of_frames; + self.lastAbsoluteCaptureTimestampMs = absolute_capture_timestamp_ms; + self.lastTimestampIsValid = timestamp_is_valid; +} + +@end + +@interface RTCAudioTrackTests : XCTestCase +@end + +@implementation RTCAudioTrackTests + +- (RTC_OBJC_TYPE(RTCAudioTrack) *)audioTrackWithFactory:(RTC_OBJC_TYPE(RTCPeerConnectionFactory) *)factory + nativeTrack:(rtc::scoped_refptr)nativeTrack { + rtc::scoped_refptr mediaTrack = nativeTrack; + RTC_OBJC_TYPE(RTCMediaStreamTrack) *track = [RTC_OBJC_TYPE(RTCMediaStreamTrack) + mediaTrackForNativeTrack:mediaTrack + factory:factory]; + EXPECT_TRUE([track isKindOfClass:[RTC_OBJC_TYPE(RTCAudioTrack) class]]); + return (RTC_OBJC_TYPE(RTCAudioTrack) *)track; +} + +- (void)testAddAndRemoveRendererAreIdempotent { + RTC_OBJC_TYPE(RTCPeerConnectionFactory) *factory = + [[RTC_OBJC_TYPE(RTCPeerConnectionFactory) alloc] init]; + rtc::scoped_refptr nativeTrack = + rtc::make_ref_counted("audio-track-idempotent"); + RTC_OBJC_TYPE(RTCAudioTrack) *track = [self audioTrackWithFactory:factory nativeTrack:nativeTrack]; + RTCAudioTrackTestRenderer *renderer = [[RTCAudioTrackTestRenderer alloc] init]; + + [track addRenderer:renderer]; + EXPECT_EQ(nativeTrack->add_sink_call_count(), 1u); + EXPECT_EQ(nativeTrack->sink_count(), 1u); + + [track addRenderer:renderer]; + EXPECT_EQ(nativeTrack->add_sink_call_count(), 1u); + EXPECT_EQ(nativeTrack->sink_count(), 1u); + + [track removeRenderer:renderer]; + EXPECT_EQ(nativeTrack->remove_sink_call_count(), 1u); + EXPECT_EQ(nativeTrack->sink_count(), 0u); + + [track removeRenderer:renderer]; + EXPECT_EQ(nativeTrack->remove_sink_call_count(), 1u); + EXPECT_EQ(nativeTrack->sink_count(), 0u); +} + +- (void)testCallbackDeliveryForwardsAllMetadata { + RTC_OBJC_TYPE(RTCPeerConnectionFactory) *factory = + [[RTC_OBJC_TYPE(RTCPeerConnectionFactory) alloc] init]; + rtc::scoped_refptr nativeTrack = + rtc::make_ref_counted("audio-track-callback"); + RTC_OBJC_TYPE(RTCAudioTrack) *track = [self audioTrackWithFactory:factory nativeTrack:nativeTrack]; + RTCAudioTrackTestRenderer *renderer = [[RTCAudioTrackTestRenderer alloc] init]; + + [track addRenderer:renderer]; + + int16_t samples[4] = {1, 2, 3, 4}; + nativeTrack->DeliverData(samples, 16, 48000, 2, 2, 1234); + + EXPECT_EQ(renderer.callbackCount, 1); + EXPECT_EQ(renderer.lastAudioData, samples); + EXPECT_EQ(renderer.lastBitsPerSample, 16); + EXPECT_EQ(renderer.lastSampleRate, 48000); + EXPECT_EQ(renderer.lastNumberOfChannels, 2u); + EXPECT_EQ(renderer.lastNumberOfFrames, 2u); + EXPECT_TRUE(renderer.lastTimestampIsValid); + EXPECT_EQ(renderer.lastAbsoluteCaptureTimestampMs, 1234); + + nativeTrack->DeliverData(samples, 16, 48000, 2, 2, absl::nullopt); + + EXPECT_EQ(renderer.callbackCount, 2); + EXPECT_FALSE(renderer.lastTimestampIsValid); +} + +- (void)testMultipleRenderersReceiveCallbacks { + RTC_OBJC_TYPE(RTCPeerConnectionFactory) *factory = + [[RTC_OBJC_TYPE(RTCPeerConnectionFactory) alloc] init]; + rtc::scoped_refptr nativeTrack = + rtc::make_ref_counted("audio-track-multi-renderer"); + RTC_OBJC_TYPE(RTCAudioTrack) *track = [self audioTrackWithFactory:factory nativeTrack:nativeTrack]; + RTCAudioTrackTestRenderer *rendererOne = [[RTCAudioTrackTestRenderer alloc] init]; + RTCAudioTrackTestRenderer *rendererTwo = [[RTCAudioTrackTestRenderer alloc] init]; + + [track addRenderer:rendererOne]; + [track addRenderer:rendererTwo]; + + EXPECT_EQ(nativeTrack->sink_count(), 2u); + + int16_t samples[4] = {1, 2, 3, 4}; + nativeTrack->DeliverData(samples, 16, 48000, 2, 2, 2000); + + EXPECT_EQ(rendererOne.callbackCount, 1); + EXPECT_EQ(rendererTwo.callbackCount, 1); + + [track removeRenderer:rendererOne]; + EXPECT_EQ(nativeTrack->sink_count(), 1u); + + nativeTrack->DeliverData(samples, 16, 48000, 2, 2, 2500); + + EXPECT_EQ(rendererOne.callbackCount, 1); + EXPECT_EQ(rendererTwo.callbackCount, 2); +} + +- (void)testTrackDeallocRemovesAllSinks { + rtc::scoped_refptr nativeTrack = + rtc::make_ref_counted("audio-track-dealloc"); + + @autoreleasepool { + RTC_OBJC_TYPE(RTCPeerConnectionFactory) *factory = + [[RTC_OBJC_TYPE(RTCPeerConnectionFactory) alloc] init]; + RTC_OBJC_TYPE(RTCAudioTrack) *track = [self audioTrackWithFactory:factory nativeTrack:nativeTrack]; + RTCAudioTrackTestRenderer *rendererOne = [[RTCAudioTrackTestRenderer alloc] init]; + RTCAudioTrackTestRenderer *rendererTwo = [[RTCAudioTrackTestRenderer alloc] init]; + + [track addRenderer:rendererOne]; + [track addRenderer:rendererTwo]; + + EXPECT_EQ(nativeTrack->sink_count(), 2u); + } + + EXPECT_EQ(nativeTrack->remove_sink_call_count(), 2u); + EXPECT_EQ(nativeTrack->sink_count(), 0u); +} + +@end