@@ -4,147 +4,142 @@ import Foundation
44import LiveKit
55#endif
66
7- /// Manages audio device configuration and speech activity handling for conversations.
8- /// Encapsulates all AudioManager interactions to keep Conversation class focused on conversation logic.
7+ /// Owns this conversation's `AudioManager.shared` configuration (mute mode, voice
8+ /// processing, capture pre-warm) and speech-activity handling, keeping
9+ /// `Conversation` focused on conversation logic.
910@MainActor
1011final class ConversationAudioManager {
11- private( set) var audioDevices : [ AudioDevice ] = [ ]
12- private( set) var selectedAudioDeviceID : String = " "
1312 private( set) var softwareMuteProcessor : SoftwareMuteProcessor ?
1413
1514 private let audioManager = AudioManager . shared
1615 private var previousSpeechActivityHandler : AudioManager . OnSpeechActivity ?
1716 private var audioSpeechHandlerInstalled = false
1817 private let logger : any Logging
1918
20- /// Callback when audio devices list changes
21- var onDevicesChanged : ( ( [ AudioDevice ] ) -> Void ) ?
22-
23- /// Callback when selected device changes
24- var onSelectedDeviceChanged : ( ( String ) -> Void ) ?
19+ // Snapshots of process-global `AudioManager.shared` state this instance
20+ // overwrote in `configure`, so `cleanup`/`deinit` can restore it instead of
21+ // leaking settings across sessions or clobbering the host app's values. A
22+ // `nil` entry means this instance never changed that setting.
23+ //
24+ // `previousCaptureDelegate` is only meaningful while `softwareMuteProcessor`
25+ // is non-nil.
26+ private var previousCaptureDelegate : AudioCustomProcessingDelegate ?
27+ private var previousVoiceProcessingBypassed : Bool ?
28+ private var previousVoiceProcessingAGCEnabled : Bool ?
2529
2630 init ( logger: any Logging ) {
2731 self . logger = logger
28- audioDevices = audioManager. inputDevices
29- selectedAudioDeviceID = audioManager. inputDevice. deviceId
30- setupInitialConfiguration ( )
3132 }
3233
3334 deinit {
34- // Reset callbacks directly since we can't call MainActor methods from deinit
35- audioManager. onDeviceUpdate = nil
35+ // Best-effort restore if torn down without a clean `cleanup()`. We can't
36+ // call MainActor methods here, but these `AudioManager.shared` accessors
37+ // are safe off the main actor.
3638 if audioSpeechHandlerInstalled {
3739 audioManager. onMutedSpeechActivity = previousSpeechActivityHandler
3840 }
41+ // Only revert the capture delegate if ours is still the installed one; a
42+ // process-wide last-write-wins slot means something else may have taken
43+ // over after us, and we must not stomp that.
44+ if let processor = softwareMuteProcessor,
45+ audioManager. capturePostProcessingDelegate. map ( { $0 as AnyObject } ) === processor
46+ {
47+ audioManager. capturePostProcessingDelegate = previousCaptureDelegate
48+ }
49+ if let bypass = previousVoiceProcessingBypassed {
50+ audioManager. isVoiceProcessingBypassed = bypass
51+ }
52+ if let agc = previousVoiceProcessingAGCEnabled {
53+ audioManager. isVoiceProcessingAGCEnabled = agc
54+ }
3955 }
4056
4157 // MARK: - Configuration
4258
4359 /// Apply audio pipeline configuration from conversation options.
44- func configure( with options: ConversationOptions ) async {
45- let config = options. audioConfiguration
60+ ///
61+ /// This is the single configuration entry point: it establishes the baseline
62+ /// device state (mute mode + engine pre-warm) and applies any caller overrides.
63+ func configure(
64+ with config: ConversationConfig ,
65+ onSpeakingWhileMutedChange: @escaping @Sendable ( Bool ) -> Void
66+ ) async {
67+ let audioConfig = config. audioConfiguration
68+ let muteMode = audioConfig? . microphoneMuteMode ?? . inputMixer
4669
47- if let mode = config? . microphoneMuteMode {
48- do {
49- try audioManager. set ( microphoneMuteMode: mode)
50- } catch {
51- logger. warning ( " Failed to set microphone mute mode " , context: [ " error " : " \( error) " ] )
52- }
70+ do {
71+ try audioManager. set ( microphoneMuteMode: muteMode. toLiveKit ( ) )
72+ } catch {
73+ logger. warning ( " Failed to set microphone mute mode " , context: [ " error " : " \( error) " ] )
5374 }
5475
55- if let bypass = config? . voiceProcessingBypassed {
76+ // Snapshot before overwriting so `cleanup` can restore the prior value
77+ // rather than leaking our override into later sessions / other consumers.
78+ if let bypass = audioConfig? . voiceProcessingBypassed {
79+ previousVoiceProcessingBypassed = audioManager. isVoiceProcessingBypassed
5680 audioManager. isVoiceProcessingBypassed = bypass
5781 }
5882
59- if let agc = config? . voiceProcessingAGCEnabled {
83+ if let agc = audioConfig? . voiceProcessingAGCEnabled {
84+ previousVoiceProcessingAGCEnabled = audioManager. isVoiceProcessingAGCEnabled
6085 audioManager. isVoiceProcessingAGCEnabled = agc
6186 }
6287
63- if let prepared = config? . recordingAlwaysPrepared {
64- do {
65- try await audioManager. setRecordingAlwaysPreparedMode ( prepared)
66- } catch {
67- logger. warning ( " Failed to set recording always prepared mode " , context: [ " error " : " \( error) " ] )
68- }
88+ // Pre-warm the capture engine via "recording always prepared" mode so the
89+ // first `setMicrophone(enabled:)` reuses a warm engine. Without it the
90+ // engine cold-starts at publish time and the first VPIO init fails with
91+ // audio-engine error -4010 (reproducible on the simulator after a fresh
92+ // permission grant).
93+ //
94+ // Intentionally NOT reverted in `cleanup`: keeping it prepared lets
95+ // back-to-back conversations reuse the warm engine, and is a benign global.
96+ do {
97+ try await audioManager. setRecordingAlwaysPreparedMode ( true )
98+ } catch {
99+ logger. warning ( " Failed to set recording always prepared mode " , context: [ " error " : " \( error) " ] )
69100 }
70101
71- configureSpeechHandler ( options : options )
72- configureSoftwareMuteProcessor ( options : options )
102+ configureSpeechHandler ( onSpeakingWhileMutedChange : onSpeakingWhileMutedChange )
103+ configureSoftwareMuteProcessor ( muteMode : muteMode , onSpeakingWhileMutedChange : onSpeakingWhileMutedChange )
73104 }
74105
75106 /// Cleanup audio state when conversation ends.
76107 func cleanup( ) {
77108 cleanupSpeechHandler ( )
78109 cleanupSoftwareMuteProcessor ( )
110+ restoreVoiceProcessingState ( )
79111 }
80112
81113 // MARK: - Private
82114
83- private func setupInitialConfiguration( ) {
84- // Set initial microphone mute mode
85- do {
86- try audioManager. set ( microphoneMuteMode: . inputMixer)
87- } catch {
88- logger. warning ( " Failed to set initial microphone mute mode " , context: [ " error " : " \( error) " ] )
89- }
90-
91- // Set recording always prepared mode asynchronously
92- Task { [ weak self] in
93- guard let self else { return }
94- do {
95- try await audioManager. setRecordingAlwaysPreparedMode ( true )
96- } catch {
97- logger. warning ( " Failed to set recording always prepared mode " , context: [ " error " : " \( error) " ] )
98- }
99- }
100-
101- // Setup device change observer
102- audioManager. onDeviceUpdate = { [ weak self] _ in
103- Task { @MainActor in
104- guard let self else { return }
105- self . audioDevices = self . audioManager. inputDevices
106- self . selectedAudioDeviceID = self . audioManager. defaultInputDevice. deviceId
107- self . onDevicesChanged ? ( self . audioDevices)
108- self . onSelectedDeviceChanged ? ( self . selectedAudioDeviceID)
109- }
115+ private func configureSpeechHandler( onSpeakingWhileMutedChange: @escaping @Sendable ( Bool ) -> Void ) {
116+ if !audioSpeechHandlerInstalled {
117+ previousSpeechActivityHandler = audioManager. onMutedSpeechActivity
118+ audioSpeechHandlerInstalled = true
110119 }
111- }
112-
113- private func configureSpeechHandler( options: ConversationOptions ) {
114- let config = options. audioConfiguration
115- let needsSpeechHandler = ( config? . onSpeechActivity != nil ) || ( options. onSpeechActivity != nil )
116-
117- if needsSpeechHandler {
118- if !audioSpeechHandlerInstalled {
119- previousSpeechActivityHandler = audioManager. onMutedSpeechActivity
120- audioSpeechHandlerInstalled = true
121- }
122- audioManager. onMutedSpeechActivity = { _, event in
123- // Handlers are @Sendable, they manage their own synchronization
124- if let handler = config? . onSpeechActivity {
125- handler ( event)
126- }
127- if let handler = options. onSpeechActivity {
128- handler ( event)
129- }
130- }
131- } else if audioSpeechHandlerInstalled {
132- cleanupSpeechHandler ( )
120+ audioManager. onMutedSpeechActivity = { _, event in
121+ // Handlers are @Sendable, they manage their own synchronization.
122+ onSpeakingWhileMutedChange ( event == . started)
133123 }
134124 }
135125
136- private func configureSoftwareMuteProcessor( options: ConversationOptions ) {
137- guard options. audioConfiguration? . useSoftwareMute == true else {
126+ private func configureSoftwareMuteProcessor(
127+ muteMode: MicrophoneMuteMode ,
128+ onSpeakingWhileMutedChange: @escaping @Sendable ( Bool ) -> Void
129+ ) {
130+ guard case let . software( speechThreshold) = muteMode else {
138131 return
139132 }
140133
141- let audioConfig = options. audioConfiguration
142- softwareMuteProcessor = SoftwareMuteProcessor (
143- onMutedSpeech: audioConfig? . onMutedSpeech,
144- mutedSpeechThresholdInDb: audioConfig? . mutedSpeechThreshold ?? - 35 ,
145- mutedSpeechThrottleInSeconds: 3.0
134+ let processor = SoftwareMuteProcessor (
135+ onSpeakingWhileMutedChange: onSpeakingWhileMutedChange,
136+ mutedSpeechThresholdInDb: speechThreshold
146137 )
147- AudioManager . shared. capturePostProcessingDelegate = softwareMuteProcessor
138+ softwareMuteProcessor = processor
139+ // Snapshot any pre-existing delegate so `cleanup` restores it rather than
140+ // nilling out a delegate this instance never owned.
141+ previousCaptureDelegate = audioManager. capturePostProcessingDelegate
142+ audioManager. capturePostProcessingDelegate = processor
148143 }
149144
150145 private func cleanupSpeechHandler( ) {
@@ -156,7 +151,44 @@ final class ConversationAudioManager {
156151 }
157152
158153 private func cleanupSoftwareMuteProcessor( ) {
159- AudioManager . shared. capturePostProcessingDelegate = nil
154+ guard let processor = softwareMuteProcessor else { return }
155+ // Restore the delegate we displaced — but only if ours is still the one
156+ // installed. The slot is process-wide last-write-wins, so if another
157+ // component set its own delegate after us, leave that in place.
158+ if audioManager. capturePostProcessingDelegate. map ( { $0 as AnyObject } ) === processor {
159+ audioManager. capturePostProcessingDelegate = previousCaptureDelegate
160+ }
161+ previousCaptureDelegate = nil
160162 softwareMuteProcessor = nil
161163 }
164+
165+ /// Restore the VPIO flags this instance overrode in `configure`. The capture
166+ /// pre-warm (`setRecordingAlwaysPreparedMode(true)`) is intentionally left
167+ /// enabled process-wide (see `configure`), so it is not restored here.
168+ private func restoreVoiceProcessingState( ) {
169+ if let bypass = previousVoiceProcessingBypassed {
170+ audioManager. isVoiceProcessingBypassed = bypass
171+ previousVoiceProcessingBypassed = nil
172+ }
173+ if let agc = previousVoiceProcessingAGCEnabled {
174+ audioManager. isVoiceProcessingAGCEnabled = agc
175+ previousVoiceProcessingAGCEnabled = nil
176+ }
177+ }
162178}
179+
180+ // MARK: - LiveKit mapping
181+
182+ private extension MicrophoneMuteMode {
183+ /// Maps to LiveKit's hardware mute mode. Software mute has no LiveKit
184+ /// equivalent — the track is kept open and muting happens in
185+ /// `SoftwareMuteProcessor`, so the engine runs with `.inputMixer` underneath.
186+ func toLiveKit( ) -> LiveKit . MicrophoneMuteMode {
187+ switch self {
188+ case . voiceProcessing: . voiceProcessing
189+ case . restart: . restart
190+ case . inputMixer, . software: . inputMixer
191+ }
192+ }
193+ }
194+
0 commit comments