diff --git a/CHANGELOG.md b/CHANGELOG.md index 148d3a518c..c3483e997f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m index e6923a6b92..cfee27cac6 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m @@ -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; @@ -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 @@ -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 @@ -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]; @@ -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); }); @@ -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); }); }); @@ -2017,7 +2140,19 @@ - (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]; @@ -2025,7 +2160,19 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { 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]; @@ -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; @@ -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 @@ -2071,11 +2239,19 @@ - (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). @@ -2083,13 +2259,21 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { @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 @@ -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 diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m index 3e4ff5d8a5..717514b6f5 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m @@ -24,6 +24,11 @@ - (instancetype)initWithFactoryId:(NSString*)factoryId _ownedTrackIds = [NSMutableSet new]; _ownedStreamIds = [NSMutableSet new]; _disposed = NO; + // Ensure ADM operations are executed sequentially in order of invocation. + _admQueue = dispatch_queue_create( + [[NSString stringWithFormat:@"io.getstream.webrtc.adm.%@", factoryId] UTF8String], + dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, + 0)); if (appleAudioConfiguration != nil) { [AudioUtils setAppleAudioConfiguration:appleAudioConfiguration]; @@ -81,19 +86,35 @@ - (void)dispose { } _disposed = YES; - if (_audioDeviceModule != nil) { - @try { - [_audioDeviceModule stopRecording]; - [_audioDeviceModule stopPlayout]; - } @catch (NSException* e) { - NSLog(@"[NativePeerConnectionFactory] stopRecording/stopPlayout failed: %@", e); - } - _audioDeviceModule.observer = nil; - _audioDeviceModule = nil; - } + RTCAudioDeviceModule* adm = _audioDeviceModule; + _audioDeviceModule = nil; + + RTCPeerConnectionFactory* factory = _factory; _factory = nil; + NSString* factoryId = _factoryId; + + if (adm != nil) { + // No further events should reach a factory that is going away. + adm.observer = nil; + // Stop through the ADM queue rather than inline: operations enqueued before + // dispose (start recording, mute, resume, ...) captured the ADM strongly and + // would otherwise run after these stops and leave capture running on a + // disposed factory. Tailing the queue makes the stops the last ADM calls. + // Enqueued asynchronously so dispose never blocks its caller and can never + // deadlock when invoked from the ADM queue itself; the block holds the last + // references to the ADM and the factory, so both outlive the queued work. + dispatch_async(_admQueue, ^{ + @try { + [adm stopRecording]; + [adm stopPlayout]; + } @catch (NSException* e) { + NSLog(@"[NativePeerConnectionFactory] stopRecording/stopPlayout failed: %@", e); + } + NSLog(@"[NativePeerConnectionFactory] ADM stopped id: %@ (factory %p)", factoryId, factory); + }); + } - NSLog(@"[NativePeerConnectionFactory] disposed id: %@", _factoryId); + NSLog(@"[NativePeerConnectionFactory] disposed id: %@", factoryId); } - (BOOL)isDisposed { diff --git a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/NativePeerConnectionFactory.h b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/NativePeerConnectionFactory.h index d67b75476e..4237fcd78c 100644 --- a/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/NativePeerConnectionFactory.h +++ b/ios/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/NativePeerConnectionFactory.h @@ -20,6 +20,12 @@ NS_ASSUME_NONNULL_BEGIN */ @property(nonatomic, strong, readonly, nullable) RTCAudioDeviceModule* audioDeviceModule; +/** + * Serial queue for every ADM operation issued against this factory: start / + * stop recording, microphone mute, suspend / resume. + */ +@property(nonatomic, strong, readonly) dispatch_queue_t admQueue; + /** Snapshot of the appleAudioConfiguration used to build this factory. */ @property(nonatomic, copy, readonly, nullable) NSDictionary* audioConfigSnapshot; @@ -51,6 +57,11 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, assign) BOOL wasPlayingBeforeSuspend; @property(nonatomic, assign) BOOL wasRecordingBeforeSuspend; +/** + * Whether audio is currently suspended. + */ +@property(nonatomic, assign) BOOL audioSuspended; + - (instancetype)initWithFactoryId:(NSString*)factoryId bypassVoiceProcessing:(BOOL)bypassVoiceProcessing networkIgnoreMask:(NSArray*)networkIgnoreMask diff --git a/lib/src/helper.dart b/lib/src/helper.dart index 4de7fd1ea7..c0d35f1dff 100644 --- a/lib/src/helper.dart +++ b/lib/src/helper.dart @@ -232,6 +232,11 @@ class Helper { /// /// When enabled, the native layer configures the ADM for stereo playout, /// bypasses voice processing, and monitors audio route changes. + /// + /// Mutually exclusive with "speaking while muted" detection: bypassing voice + /// processing moves the ADM's mute to the input mixer, so + /// `NativePeerConnectionFactory.setMicrophoneMuted` still mutes capture but + /// emits no `onSpeechActivityChanged` events while muted. static Future setiOSStereoPlayoutPreferred(bool preferred) => IosAudioManagement.setStereoPlayoutPreferred(preferred); diff --git a/lib/src/native/ios/audio_management.dart b/lib/src/native/ios/audio_management.dart index 0f2288c228..3d24748def 100644 --- a/lib/src/native/ios/audio_management.dart +++ b/lib/src/native/ios/audio_management.dart @@ -15,6 +15,10 @@ class IosAudioManagement { /// When enabled, the native layer sets `prefersStereoPlayout` on the ADM, /// bypasses voice processing, sets mute mode to input mixer, and monitors /// audio route changes to refresh stereo playout state. + /// + /// Because this bypasses VPIO and moves the ADM's mute to the input mixer, it + /// disables "speaking while muted" detection: `setMicrophoneMuted` still mutes + /// capture, but no `onSpeechActivityChanged` events are emitted while muted. static Future setStereoPlayoutPreferred(bool preferred) async { if (kIsWeb || !WebRTC.platformIsIOS) return; diff --git a/lib/src/native/native_peer_connection_factory.dart b/lib/src/native/native_peer_connection_factory.dart index 67c1127aef..1e9be55d97 100644 --- a/lib/src/native/native_peer_connection_factory.dart +++ b/lib/src/native/native_peer_connection_factory.dart @@ -203,6 +203,67 @@ class NativePeerConnectionFactory { } } + /// Whether ADM-level microphone mute is available on the current platform. + /// + /// iOS and macOS only — see [setMicrophoneMuted]. + static bool get isAdmMicrophoneMuteSupported => + WebRTC.platformIsIOS || WebRTC.platformIsMacOS; + + /// Mutes/unmutes mic at the ADM level (**iOS/macOS only**), allowing detection + /// of "speaking while muted." + /// + /// No Android support: mute the local audio track there instead. + /// Throws [UnsupportedError] elsewhere; guard with + /// [isAdmMicrophoneMuteSupported]. + Future setMicrophoneMuted(bool muted) async { + _checkAdmMuteSupported('setMicrophoneMuted'); + _checkDisposed('setMicrophoneMuted'); + try { + await WebRTC.invokeMethod( + 'appleAdmSetMicrophoneMuted', + { + 'factoryId': factoryId, + 'muted': muted, + }, + ); + } on PlatformException catch (e) { + throw 'Unable to set microphone muted: ${e.message}'; + } + } + + /// Reads back the audio-device-module microphone mute state. + /// + /// **iOS / macOS only**, same rationale as [setMicrophoneMuted]. Throws + /// [UnsupportedError] elsewhere. + /// + /// Note that the native readback cannot distinguish "not muted" from "the + /// ADM could not be queried": both surface as `false`. Treat a `false` here + /// as "not known to be muted" rather than proof that capture is live. + Future isMicrophoneMuted() async { + _checkAdmMuteSupported('isMicrophoneMuted'); + _checkDisposed('isMicrophoneMuted'); + try { + final response = await WebRTC.invokeMethod( + 'appleAdmIsMicrophoneMuted', + { + 'factoryId': factoryId, + }, + ); + return response == true; + } on PlatformException catch (e) { + throw 'Unable to read microphone muted: ${e.message}'; + } + } + + void _checkAdmMuteSupported(String method) { + if (!isAdmMicrophoneMuteSupported) { + throw UnsupportedError( + 'NativePeerConnectionFactory.$method is only supported on iOS and ' + 'macOS. Mute the local audio track instead.', + ); + } + } + /// Disposes the underlying native factory. Future dispose() async { if (_disposed) { diff --git a/lib/src/web/native_peer_connection_factory.dart b/lib/src/web/native_peer_connection_factory.dart index 49efa518ab..88925b8574 100644 --- a/lib/src/web/native_peer_connection_factory.dart +++ b/lib/src/web/native_peer_connection_factory.dart @@ -48,6 +48,15 @@ class NativePeerConnectionFactory { Future startLocalRecording() async => throw UnimplementedError(); Future stopLocalRecording() async => throw UnimplementedError(); + /// ADM-level microphone mute is iOS / macOS only. + static bool get isAdmMicrophoneMuteSupported => false; + + Future setMicrophoneMuted(bool muted) async => throw UnsupportedError( + 'ADM-level microphone mute is not supported on web'); + + Future isMicrophoneMuted() async => throw UnsupportedError( + 'ADM-level microphone mute is not supported on web'); + Future suspendAudio() async {} Future resumeAudio() async {} diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m index c5c06ede47..7d88bdfa61 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/FlutterWebRTCPlugin.m @@ -153,7 +153,6 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel registrar:(NSObject*)registrar messenger:(NSObject*)messenger withTextures:(NSObject*)textures { - self = [super init]; sharedSingleton = self; @@ -170,7 +169,6 @@ - (instancetype)initWithChannel:(FlutterMethodChannel*)channel _speakerOnButPreferBluetooth = NO; _eventChannel = eventChannel; _audioManager = AudioManager.sharedInstance; - } NSDictionary* fieldTrials = @{kRTCFieldTrialUseNWPathMonitor : kRTCFieldTrialEnabledValue}; @@ -1680,8 +1678,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 @@ -1708,8 +1714,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 @@ -1725,6 +1739,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]; @@ -1736,15 +1823,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); }); @@ -1760,20 +1863,35 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { return; } RTCAudioDeviceModule* adm = nf.audioDeviceModule; - BOOL restorePlaying = nf.wasPlayingBeforeSuspend; - BOOL restoreRecording = nf.wasRecordingBeforeSuspend; - 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) { + 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); }); }); @@ -1784,7 +1902,19 @@ - (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]; @@ -1792,7 +1922,19 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { 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]; @@ -1804,8 +1946,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; @@ -1820,16 +1976,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 @@ -1838,11 +2001,19 @@ - (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). @@ -1850,13 +2021,21 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { @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 diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m index 0145507b92..211a13ac10 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/NativePeerConnectionFactory.m @@ -23,6 +23,11 @@ - (instancetype)initWithFactoryId:(NSString*)factoryId _ownedTrackIds = [NSMutableSet new]; _ownedStreamIds = [NSMutableSet new]; _disposed = NO; + // Ensure ADM operations are executed sequentially in order of invocation. + _admQueue = dispatch_queue_create( + [[NSString stringWithFormat:@"io.getstream.webrtc.adm.%@", factoryId] UTF8String], + dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, + 0)); VideoDecoderFactory* decoderFactory = [[VideoDecoderFactory alloc] init]; VideoEncoderFactory* encoderFactory = [[VideoEncoderFactory alloc] init]; @@ -76,19 +81,35 @@ - (void)dispose { } _disposed = YES; - if (_audioDeviceModule != nil) { - @try { - [_audioDeviceModule stopRecording]; - [_audioDeviceModule stopPlayout]; - } @catch (NSException* e) { - NSLog(@"[NativePeerConnectionFactory] stopRecording/stopPlayout failed: %@", e); - } - _audioDeviceModule.observer = nil; - _audioDeviceModule = nil; - } + RTCAudioDeviceModule* adm = _audioDeviceModule; + _audioDeviceModule = nil; + + RTCPeerConnectionFactory* factory = _factory; _factory = nil; + NSString* factoryId = _factoryId; + + if (adm != nil) { + // No further events should reach a factory that is going away. + adm.observer = nil; + // Stop through the ADM queue rather than inline: operations enqueued before + // dispose (start recording, mute, resume, ...) captured the ADM strongly and + // would otherwise run after these stops and leave capture running on a + // disposed factory. Tailing the queue makes the stops the last ADM calls. + // Enqueued asynchronously so dispose never blocks its caller and can never + // deadlock when invoked from the ADM queue itself; the block holds the last + // references to the ADM and the factory, so both outlive the queued work. + dispatch_async(_admQueue, ^{ + @try { + [adm stopRecording]; + [adm stopPlayout]; + } @catch (NSException* e) { + NSLog(@"[NativePeerConnectionFactory] stopRecording/stopPlayout failed: %@", e); + } + NSLog(@"[NativePeerConnectionFactory] ADM stopped id: %@ (factory %p)", factoryId, factory); + }); + } - NSLog(@"[NativePeerConnectionFactory] disposed id: %@", _factoryId); + NSLog(@"[NativePeerConnectionFactory] disposed id: %@", factoryId); } - (BOOL)isDisposed { diff --git a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/NativePeerConnectionFactory.h b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/NativePeerConnectionFactory.h index d67b75476e..4237fcd78c 100644 --- a/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/NativePeerConnectionFactory.h +++ b/macos/stream_webrtc_flutter/Sources/stream_webrtc_flutter/include/stream_webrtc_flutter/NativePeerConnectionFactory.h @@ -20,6 +20,12 @@ NS_ASSUME_NONNULL_BEGIN */ @property(nonatomic, strong, readonly, nullable) RTCAudioDeviceModule* audioDeviceModule; +/** + * Serial queue for every ADM operation issued against this factory: start / + * stop recording, microphone mute, suspend / resume. + */ +@property(nonatomic, strong, readonly) dispatch_queue_t admQueue; + /** Snapshot of the appleAudioConfiguration used to build this factory. */ @property(nonatomic, copy, readonly, nullable) NSDictionary* audioConfigSnapshot; @@ -51,6 +57,11 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, assign) BOOL wasPlayingBeforeSuspend; @property(nonatomic, assign) BOOL wasRecordingBeforeSuspend; +/** + * Whether audio is currently suspended. + */ +@property(nonatomic, assign) BOOL audioSuspended; + - (instancetype)initWithFactoryId:(NSString*)factoryId bypassVoiceProcessing:(BOOL)bypassVoiceProcessing networkIgnoreMask:(NSArray*)networkIgnoreMask