Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

[3.0.2] - 2026.07.29

* [iOS/macOS] Added `NativePeerConnectionFactory.setMicrophoneMuted` (`appleAdmSetMicrophoneMuted` method channel): mutes microphone capture at the audio-device-module level while the audio engine keeps running. Mutes inside the Voice-Processing I/O unit, arming Apple's muted-talker detection so the plugin emits `onSpeechActivityChanged` events while the user speaks muted.
* [iOS/macOS] Added `NativePeerConnectionFactory.isMicrophoneMuted` (`appleAdmIsMicrophoneMuted` method channel): reads the ADM's own mute state.
* [iOS/macOS] ADM operations (start/stop recording, microphone mute, suspend/resume) now run on a per-factory serial queue instead of the global concurrent queue. Two rapid `setMicrophoneMuted` calls could previously be applied out of order, leaving the microphone in the wrong state indefinitely.
* [iOS/macOS] `suspendAudioPeerConnectionFactory` / `resumeAudioPeerConnectionFactory` are now idempotent. A second suspend used to re-snapshot the already-stopped ADM as "was neither playing nor recording", so the following resume restored nothing and audio stayed dead.
* [iOS] fix: trigger the screen-share broadcast picker (`RPSystemBroadcastPickerView`) via public APIs instead of the private `buttonPressed:` selector.

[3.0.1] - 2026.07.10
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,10 @@ - (void)didSessionRouteChange:(NSNotification*)notification {
for (NativePeerConnectionFactory* nf in snapshot) {
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
if (adm != nil) {
[adm refreshStereoPlayoutState];
// Run on the factory's serial ADM queue
dispatch_async(nf.admQueue, ^{
[adm refreshStereoPlayoutState];
});
}
}
strongSelf->_stereoRefreshDebounceBlock = nil;
Expand Down Expand Up @@ -1909,8 +1912,16 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
return;
}
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
// Run on background queue
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
if (adm == nil) {
result([FlutterError
errorWithCode:@"startLocalRecording"
message:[NSString
stringWithFormat:@"factory %@ has no audio device module", factoryId]
details:nil]);
return;
}
// Run on the factory's serial ADM queue
dispatch_async(nf.admQueue, ^{
NSInteger admResult = [adm initAndStartRecording];

// Return to main queue
Expand All @@ -1937,8 +1948,16 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
return;
}
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
// Run on background queue
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
if (adm == nil) {
result([FlutterError
errorWithCode:@"stopLocalRecording"
message:[NSString
stringWithFormat:@"factory %@ has no audio device module", factoryId]
details:nil]);
return;
}
// Run on the factory's serial ADM queue
dispatch_async(nf.admQueue, ^{
NSInteger admResult = [adm stopRecording];

// Return to main queue
Expand All @@ -1954,6 +1973,79 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
}
});
});
} else if ([@"appleAdmSetMicrophoneMuted" isEqualToString:call.method]) {
NSString* factoryId = call.arguments[@"factoryId"];
NSNumber* muted = call.arguments[@"muted"];
NativePeerConnectionFactory* nf = [self resolveFactoryForId:factoryId];
if (nf == nil) {
result([FlutterError
errorWithCode:@"appleAdmSetMicrophoneMuted"
message:[NSString stringWithFormat:@"unknown factoryId %@", factoryId]
details:nil]);
return;
}
if (![muted isKindOfClass:[NSNumber class]]) {
result([FlutterError errorWithCode:@"appleAdmSetMicrophoneMuted"
message:@"missing or non-boolean 'muted' argument"
details:nil]);
return;
}
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
if (adm == nil) {
result([FlutterError
errorWithCode:@"appleAdmSetMicrophoneMuted"
message:[NSString
stringWithFormat:@"factory %@ has no audio device module", factoryId]
details:nil]);
return;
}
// Run on the factory's serial ADM queue: mute is an absolute set, so two
// rapid toggles must not be reordered.
dispatch_async(nf.admQueue, ^{
// Mutes capture inside the Voice-Processing I/O unit while the audio
// engine keeps running, so Apple's muted-talker detection stays armed
// and the ADM keeps delivering didReceiveSpeechActivityEvent while the
// user speaks muted.
NSInteger admResult = [adm setMicrophoneMuted:muted.boolValue];

// Return to main queue
dispatch_async(dispatch_get_main_queue(), ^{
if (admResult == 0) {
result(nil);
} else {
result([FlutterError
errorWithCode:[NSString stringWithFormat:@"%@ failed", call.method]
message:[NSString stringWithFormat:@"Error: adm api failed with code: %ld",
(long)admResult]
details:nil]);
}
});
});
} else if ([@"appleAdmIsMicrophoneMuted" isEqualToString:call.method]) {
NSString* factoryId = call.arguments[@"factoryId"];
NativePeerConnectionFactory* nf = [self resolveFactoryForId:factoryId];
if (nf == nil) {
result([FlutterError
errorWithCode:@"appleAdmIsMicrophoneMuted"
message:[NSString stringWithFormat:@"unknown factoryId %@", factoryId]
details:nil]);
return;
}
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
if (adm == nil) {
result([FlutterError
errorWithCode:@"appleAdmIsMicrophoneMuted"
message:[NSString
stringWithFormat:@"factory %@ has no audio device module", factoryId]
details:nil]);
return;
}
dispatch_async(nf.admQueue, ^{
BOOL muted = adm.isMicrophoneMuted;
dispatch_async(dispatch_get_main_queue(), ^{
result(@(muted));
});
});
} else if ([@"suspendAudioPeerConnectionFactory" isEqualToString:call.method]) {
NSString* factoryId = call.arguments[@"factoryId"];
NativePeerConnectionFactory* nf = [self resolveFactoryForId:factoryId];
Expand All @@ -1965,15 +2057,31 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
return;
}
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
BOOL wasPlaying = adm.isPlaying;
BOOL wasRecording = adm.isRecording;
nf.wasPlayingBeforeSuspend = wasPlaying;
nf.wasRecordingBeforeSuspend = wasRecording;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
if (wasRecording)
[adm stopRecording];
if (wasPlaying)
[adm stopPlayout];
if (adm == nil) {
// Nothing to suspend: the factory already released its ADM.
dispatch_async(nf.admQueue, ^{
nf.wasPlayingBeforeSuspend = NO;
nf.wasRecordingBeforeSuspend = NO;
nf.audioSuspended = NO;
dispatch_async(dispatch_get_main_queue(), ^{
result(nil);
});
});
return;
}
dispatch_async(nf.admQueue, ^{
// Idempotent: only the first suspend snapshots and stops.
if (!nf.audioSuspended) {
BOOL wasPlaying = adm.isPlaying;
BOOL wasRecording = adm.isRecording;
nf.wasPlayingBeforeSuspend = wasPlaying;
nf.wasRecordingBeforeSuspend = wasRecording;
nf.audioSuspended = YES;
if (wasRecording)
[adm stopRecording];
if (wasPlaying)
[adm stopPlayout];
}
dispatch_async(dispatch_get_main_queue(), ^{
result(nil);
});
Expand All @@ -1989,24 +2097,39 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
return;
}
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
NSDictionary* audioConfigSnapshot = nf.audioConfigSnapshot;
BOOL restorePlaying = nf.wasPlayingBeforeSuspend;
BOOL restoreRecording = nf.wasRecordingBeforeSuspend;
if (audioConfigSnapshot != nil) {
[AudioUtils setAppleAudioConfiguration:audioConfigSnapshot];
}
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
if (restorePlaying) {
[adm initPlayout];
[adm startPlayout];
}
if (restoreRecording) {
[adm initRecording];
[adm startRecording];
}
dispatch_async(dispatch_get_main_queue(), ^{
if (adm == nil) {
// Nothing to restore: the factory already released its ADM.
dispatch_async(nf.admQueue, ^{
nf.wasPlayingBeforeSuspend = NO;
nf.wasRecordingBeforeSuspend = NO;
nf.audioSuspended = NO;
dispatch_async(dispatch_get_main_queue(), ^{
result(nil);
});
});
return;
}
dispatch_async(nf.admQueue, ^{
if (nf.audioSuspended) {
NSDictionary* audioConfigSnapshot = nf.audioConfigSnapshot;
if (audioConfigSnapshot != nil) {
[AudioUtils setAppleAudioConfiguration:audioConfigSnapshot];
}
BOOL restorePlaying = nf.wasPlayingBeforeSuspend;
BOOL restoreRecording = nf.wasRecordingBeforeSuspend;
if (restorePlaying) {
[adm initPlayout];
[adm startPlayout];
}
if (restoreRecording) {
[adm initRecording];
[adm startRecording];
}
nf.wasPlayingBeforeSuspend = NO;
nf.wasRecordingBeforeSuspend = NO;
nf.audioSuspended = NO;
}
dispatch_async(dispatch_get_main_queue(), ^{
result(nil);
});
});
Expand All @@ -2017,15 +2140,39 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
result(@(NO));
return;
}
result(@(nf.audioDeviceModule.isVoiceProcessingEnabled));
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
if (adm == nil) {
result(@(NO));
return;
}
dispatch_async(nf.admQueue, ^{
BOOL enabled = adm.isVoiceProcessingEnabled;

// Return to main queue
dispatch_async(dispatch_get_main_queue(), ^{
result(@(enabled));
});
});
} else if ([@"isVoiceProcessingBypassed" isEqualToString:call.method]) {
NSString* factoryId = call.arguments[@"factoryId"];
NativePeerConnectionFactory* nf = [self resolveFactoryForId:factoryId];
if (nf == nil) {
result(@(NO));
return;
}
result(@(nf.audioDeviceModule.isVoiceProcessingBypassed));
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
if (adm == nil) {
result(@(NO));
return;
}
dispatch_async(nf.admQueue, ^{
BOOL bypassed = adm.isVoiceProcessingBypassed;

// Return to main queue
dispatch_async(dispatch_get_main_queue(), ^{
result(@(bypassed));
});
});
} else if ([@"setIsVoiceProcessingBypassed" isEqualToString:call.method]) {
NSString* factoryId = call.arguments[@"factoryId"];
NativePeerConnectionFactory* nf = [self resolveFactoryForId:factoryId];
Expand All @@ -2037,8 +2184,22 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
return;
}
NSNumber* value = call.arguments[@"value"];
nf.audioDeviceModule.voiceProcessingBypassed = value.boolValue;
result(nil);
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
if (adm == nil) {
// Factory already released its ADM: nothing to bypass.
result(nil);
return;
}
BOOL bypassed = value.boolValue;
// Run on the factory's serial ADM queue
dispatch_async(nf.admQueue, ^{
adm.voiceProcessingBypassed = bypassed;

// Return to main queue
dispatch_async(dispatch_get_main_queue(), ^{
result(nil);
});
});
} else if ([@"setStereoPlayoutPreferred" isEqualToString:call.method]) {
NSNumber* value = call.arguments[@"preferred"];
BOOL preferred = value.boolValue;
Expand All @@ -2053,16 +2214,23 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
result(nil);
return;
}
dispatch_group_t group = dispatch_group_create();
for (NativePeerConnectionFactory* nf in snapshot) {
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
if (adm == nil)
continue;
adm.prefersStereoPlayout = preferred;
adm.voiceProcessingBypassed = preferred;
[adm setMuteMode:preferred ? RTCAudioEngineMuteModeInputMixer
: RTCAudioEngineMuteModeVoiceProcessing];
dispatch_group_async(group, nf.admQueue, ^{
adm.prefersStereoPlayout = preferred;
adm.voiceProcessingBypassed = preferred;
[adm setMuteMode:preferred ? RTCAudioEngineMuteModeInputMixer
: RTCAudioEngineMuteModeVoiceProcessing];
});
}
result(nil);

// Return to main queue
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
result(nil);
});
} else if ([@"isStereoPlayoutEnabled" isEqualToString:call.method]) {
// Sample any active factory's ADM. Stereo preference is process-wide
// so the value is consistent across factories; use the implicit one
Expand All @@ -2071,25 +2239,41 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
@synchronized(self) {
nf = self.factories.allValues.firstObject;
}
if (nf == nil || nf.audioDeviceModule == nil) {
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
if (nf == nil || adm == nil) {
result(@(NO));
return;
}
result(@(nf.audioDeviceModule.isStereoPlayoutEnabled));
dispatch_async(nf.admQueue, ^{
BOOL enabled = adm.isStereoPlayoutEnabled;

// Return to main queue
dispatch_async(dispatch_get_main_queue(), ^{
result(@(enabled));
});
});
} else if ([@"refreshStereoPlayoutState" isEqualToString:call.method]) {
// Broadcast across every factory's ADM (stereo preference is
// process-wide).
NSArray<NativePeerConnectionFactory*>* snapshot;
@synchronized(self) {
snapshot = [self.factories.allValues copy];
}
dispatch_group_t group = dispatch_group_create();
for (NativePeerConnectionFactory* nf in snapshot) {
RTCAudioDeviceModule* adm = nf.audioDeviceModule;
if (adm != nil) {
[adm refreshStereoPlayoutState];
// Run on the factory's serial ADM queue
dispatch_group_async(group, nf.admQueue, ^{
[adm refreshStereoPlayoutState];
});
}
}
result(nil);

// Return to main queue
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
result(nil);
});
} else {
// Frame cryptor was deactivated alongside the iOS ambient-factory removal —
// it routed factory creation through the ambient ADM and Stream SDK does
Expand Down Expand Up @@ -2186,9 +2370,14 @@ - (void)applyPersistentAdmStateToFactory:(NativePeerConnectionFactory*)factory {
if (adm == nil) {
return;
}
adm.prefersStereoPlayout = YES;
adm.voiceProcessingBypassed = YES;
[adm setMuteMode:RTCAudioEngineMuteModeInputMixer];
// Serialize with the factory's ADM queue like every other ADM mutation. The
// queue is empty at this point — the factory was just built and its id has
// not reached Dart yet — so this does not block meaningfully.
dispatch_sync(factory.admQueue, ^{
adm.prefersStereoPlayout = YES;
adm.voiceProcessingBypassed = YES;
[adm setMuteMode:RTCAudioEngineMuteModeInputMixer];
});
}
#endif

Expand Down
Loading
Loading