@@ -360,10 +360,12 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
360360 let automatic = args [ " automatic " ] as? Bool ?? false
361361 let selectCategoryByEngineState = args [ " selectCategoryByEngineState " ] as? Bool ?? false
362362 let forceSpeakerOutput = args [ " forceSpeakerOutput " ] as? Bool ?? false
363+ let preferSpeakerOutput = args [ " preferSpeakerOutput " ] as? Bool ?? false
363364 audioEngineObserver? . updatePolicy ( configuration,
364365 automaticManagementEnabled: automatic,
365366 selectCategoryByEngineState: selectCategoryByEngineState,
366- forceSpeakerOutput: forceSpeakerOutput)
367+ forceSpeakerOutput: forceSpeakerOutput,
368+ preferSpeakerOutput: preferSpeakerOutput)
367369
368370 let shouldApplyNow = !automatic || ( audioEngineObserver? . isSessionActive ?? false )
369371 guard shouldApplyNow else {
@@ -524,11 +526,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
524526 if admResult == 0 {
525527 result ( nil )
526528 } else {
527- result ( FlutterError (
528- code: " setEngineAvailability " ,
529- message: " Audio engine returned error code: \( admResult) " ,
530- details: nil
531- ) )
529+ result ( LiveKitPlugin . flutterError ( forAudioEngineResult: admResult,
530+ fallbackCode: " setEngineAvailability " ) )
532531 }
533532 }
534533 }
@@ -606,11 +605,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
606605 if admResult == 0 {
607606 result ( nil )
608607 } else {
609- result ( FlutterError (
610- code: " applyFailed " ,
611- message: " Audio engine returned error code: \( admResult) " ,
612- details: nil
613- ) )
608+ // Permission and audio session failures get their own codes so
609+ // Dart does not report them as audio processing failures.
610+ result ( LiveKitPlugin . flutterError ( forAudioEngineResult: admResult,
611+ fallbackCode: " applyFailed " ) )
614612 }
615613 }
616614 }
@@ -786,14 +784,46 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
786784 }
787785}
788786
789- #if !os(macOS)
790- @available ( iOS 13 . 0 , * )
791787extension LiveKitPlugin {
792788 /// SDK-side audio engine error code (mirrors client-sdk-swift): returned
793789 /// from a delegate callback to make WebRTC abort / roll back the engine
794790 /// operation when the audio session cannot be configured.
795791 static let kAudioEngineErrorFailedToConfigureAudioSession = - 4100
796792
793+ /// Error codes originating from the WebRTC AudioEngineDevice. Keep in sync
794+ /// with `audio_engine_device.h` in the webrtc-sdk fork.
795+ static let kAudioEngineErrorInsufficientDevicePermission = - 9000
796+ static let kAudioEngineErrorAudioSessionInvalidCategory = - 9001
797+
798+ /// Maps a non-zero audio device module result to a `FlutterError` whose code
799+ /// the Dart side can act on. Codes with a known cause get their own error
800+ /// code, mirroring client-sdk-swift's `checkAdmResult`. Anything else falls
801+ /// back to `fallbackCode` with the raw value in the message.
802+ static func flutterError( forAudioEngineResult result: Int , fallbackCode: String ) -> FlutterError {
803+ switch result {
804+ case kAudioEngineErrorInsufficientDevicePermission:
805+ return FlutterError ( code: " deviceAccessDenied " ,
806+ message: " Microphone permission is not granted (audio engine error \( result) ) " ,
807+ details: result)
808+ case kAudioEngineErrorAudioSessionInvalidCategory:
809+ return FlutterError ( code: " audioSessionInvalidCategory " ,
810+ message: " Audio session category does not support recording (audio engine error \( result) ) " ,
811+ details: result)
812+ case kAudioEngineErrorFailedToConfigureAudioSession:
813+ return FlutterError ( code: " audioSessionConfigureFailed " ,
814+ message: " Failed to configure the audio session (audio engine error \( result) ) " ,
815+ details: result)
816+ default :
817+ return FlutterError ( code: fallbackCode,
818+ message: " Audio engine returned error code: \( result) " ,
819+ details: result)
820+ }
821+ }
822+ }
823+
824+ #if !os(macOS)
825+ @available ( iOS 13 . 0 , * )
826+ extension LiveKitPlugin {
797827 /// Applies an `RTCAudioSessionConfiguration` to the shared `RTCAudioSession`.
798828 /// Returns `nil` on success or the thrown error. Safe to call on any thread.
799829 static func applyAudioSessionConfiguration( _ configuration: RTCAudioSessionConfiguration ,
@@ -869,6 +899,13 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
869899 private weak var channel : FlutterMethodChannel ?
870900
871901 #if !os(macOS)
902+ // Policy pushed from Dart, if any. It is an override: when nothing has been
903+ // pushed yet (recording before the room connects, or an engine start driven
904+ // from native before the Flutter side exists) the observer resolves a
905+ // built-in preset from engine state instead, so the engine never enables
906+ // against the app-default soloAmbient category. Mirrors the Swift SDK's
907+ // AudioSessionEngineObserver, which derives the session from engine state
908+ // alone.
872909 private var cachedConfiguration : RTCAudioSessionConfiguration ?
873910 // When true, the category is chosen from the live engine state at apply time
874911 // (playAndRecord while recording, playback for playout-only) rather than
@@ -878,6 +915,9 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
878915 // override or manual mode, where the config is applied verbatim.
879916 private var selectCategoryByEngineState = false
880917 private var forceSpeakerOutput = false
918+ // Speaker preference for the built-in recording preset (videoChat routes to
919+ // the speaker, voiceChat to the receiver), same as the Dart-side policy.
920+ private var preferSpeakerOutput = false
881921 private var isAutomaticManagementEnabled = true
882922 // False when an external call system (CallKit) owns session activation:
883923 // configurations are applied without activating, and the session is never
@@ -916,13 +956,15 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
916956 func updatePolicy( _ configuration: RTCAudioSessionConfiguration ,
917957 automaticManagementEnabled: Bool ,
918958 selectCategoryByEngineState: Bool ,
919- forceSpeakerOutput: Bool ) {
959+ forceSpeakerOutput: Bool ,
960+ preferSpeakerOutput: Bool ) {
920961 let cachedConfiguration = copyConfiguration ( configuration)
921962 lock. lock ( )
922963 self . cachedConfiguration = cachedConfiguration
923964 self . isAutomaticManagementEnabled = automaticManagementEnabled
924965 self . selectCategoryByEngineState = selectCategoryByEngineState
925966 self . forceSpeakerOutput = forceSpeakerOutput
967+ self . preferSpeakerOutput = preferSpeakerOutput
926968 lock. unlock ( )
927969 }
928970
@@ -972,8 +1014,23 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
9721014 /// category would leave playAndRecord-only mode/options (e.g. videoChat,
9731015 /// allowBluetooth) that are invalid for the playback category. Mirrors the
9741016 /// Swift SDK's `.playback` preset (playback + spokenAudio + mixWithOthers).
1017+ ///
1018+ /// With no pushed config and automatic management on, the built-in
1019+ /// recording preset stands in for the Dart policy, so the result is the
1020+ /// same as if the default policy had been pushed. Returns `nil` only in
1021+ /// manual mode with nothing pushed, where the app owns the session.
9751022 private func effectiveConfigurationLocked( isRecordingEnabled: Bool ) -> RTCAudioSessionConfiguration ? {
976- guard let configuration = cachedConfiguration else { return nil }
1023+ let configuration : RTCAudioSessionConfiguration
1024+ let selectCategoryByEngineState : Bool
1025+ if let cachedConfiguration {
1026+ configuration = cachedConfiguration
1027+ selectCategoryByEngineState = self . selectCategoryByEngineState
1028+ } else if isAutomaticManagementEnabled {
1029+ configuration = defaultRecordingConfigurationLocked ( )
1030+ selectCategoryByEngineState = true
1031+ } else {
1032+ return nil
1033+ }
9771034 guard selectCategoryByEngineState, !isRecordingEnabled else { return configuration }
9781035
9791036 let playback = copyConfiguration ( configuration)
@@ -983,6 +1040,21 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate {
9831040 return playback
9841041 }
9851042
1043+ /// Built-in playAndRecord preset used until Dart pushes a policy. Must be
1044+ /// called with `lock` held.
1045+ ///
1046+ /// Keep in sync with the automatic-mode branch of
1047+ /// `ResolvedAudioSessionPolicy.appleConfiguration` in
1048+ /// `lib/src/audio/audio_session_policy.dart`, so a later push of the default
1049+ /// policy (on connect) does not change the live session.
1050+ private func defaultRecordingConfigurationLocked( ) -> RTCAudioSessionConfiguration {
1051+ let configuration = RTCAudioSessionConfiguration . webRTC ( )
1052+ configuration. category = AVAudioSession . Category. playAndRecord. rawValue
1053+ configuration. categoryOptions = [ . allowBluetooth, . allowBluetoothA2DP, . allowAirPlay]
1054+ configuration. mode = ( preferSpeakerOutput ? AVAudioSession . Mode. videoChat : AVAudioSession . Mode. voiceChat) . rawValue
1055+ return configuration
1056+ }
1057+
9861058 private func copyConfiguration( _ configuration: RTCAudioSessionConfiguration ) -> RTCAudioSessionConfiguration {
9871059 let copy = RTCAudioSessionConfiguration ( )
9881060 copy. category = configuration. category
0 commit comments