Skip to content

Commit 5cb4607

Browse files
committed
feat(audio): unify MicrophoneMuteMode, fix AudioManager.shared save/restore
- AudioPipelineConfiguration: MicrophoneMuteMode is no longer a LiveKit re-export -- it's the SDK's own 4-case enum (.inputMixer/.restart/ .voiceProcessing/.software(speechThreshold:)), folding in what used to be 4 separate fields (microphoneMuteMode/useSoftwareMute/ mutedSpeechThreshold + the onSpeechActivity/onMutedSpeech callback split). recordingAlwaysPrepared opt-out is removed -- engine pre-warm is now unconditional. LiveKit's SpeechActivityEvent no longer leaks into this public config surface. - SoftwareMuteProcessor: onMutedSpeech(MutedSpeechEvent) (throttled, periodic, level-based) becomes onSpeakingWhileMutedChange(Bool) (fires once on the started/ended edge of a hangover-latched speech segment, including on unmute mid-segment). mutedSpeechThrottleInSeconds is gone. - ConversationAudioManager: configure(with:) now snapshots and restores AudioManager.shared's capturePostProcessingDelegate/ isVoiceProcessingBypassed/isVoiceProcessingAGCEnabled around this instance's lifetime (guarded by an identity check on restore) instead of unconditionally clearing them on cleanup -- fixes clobbering another component that took over the process-wide slot. setRecordingAlwaysPreparedMode is now awaited inside configure() (called before WebRTCConnectionManager.connect) instead of fired in the background at init -- this adds a new async hop to the front of the startup critical path; worth a real-device timing check given this SDK's startup-latency history, but not blocking this PR.
1 parent 12e3cd0 commit 5cb4607

4 files changed

Lines changed: 243 additions & 185 deletions

File tree

Sources/ElevenLabs/Internal/Conversation/ConversationAudioManager.swift

Lines changed: 122 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -4,147 +4,142 @@ import Foundation
44
import 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
1011
final 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+

Sources/ElevenLabs/Internal/Utilities/SoftwareMuteProcessor.swift

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,35 +19,49 @@ final class SoftwareMuteProcessor: NSObject, @unchecked Sendable, AudioCustomPro
1919

2020
private var lock = os_unfair_lock_s()
2121
private var isMuted: Bool = false
22-
private var lastNotificationTime: Date = .distantPast
2322

2423
private var consecutiveAboveCount: Int = 0
2524
private var consecutiveBelowCount: Int = 0
2625
private var hangoverLatched: Bool = false
2726

28-
private let onMutedSpeech: (@Sendable (MutedSpeechEvent) -> Void)?
27+
private let onSpeakingWhileMutedChange: (@Sendable (Bool) -> Void)?
2928
private let mutedSpeechThresholdInDb: Float
30-
private let mutedSpeechThrottleInSeconds: TimeInterval
3129

3230
init(
33-
onMutedSpeech: (@Sendable (MutedSpeechEvent) -> Void)?,
34-
mutedSpeechThresholdInDb: Float = -35,
35-
mutedSpeechThrottleInSeconds: TimeInterval = 3.0
31+
onSpeakingWhileMutedChange: (@Sendable (Bool) -> Void)?,
32+
mutedSpeechThresholdInDb: Float = -35
3633
) {
37-
self.onMutedSpeech = onMutedSpeech
34+
self.onSpeakingWhileMutedChange = onSpeakingWhileMutedChange
3835
self.mutedSpeechThresholdInDb = mutedSpeechThresholdInDb
39-
self.mutedSpeechThrottleInSeconds = mutedSpeechThrottleInSeconds
36+
}
37+
38+
/// The current software-gate mute state. The source of truth for
39+
/// `.software` mute mode, where the capture track stays open and the
40+
/// hardware mic flag would misleadingly read as unmuted.
41+
var muted: Bool {
42+
os_unfair_lock_lock(&lock)
43+
defer { os_unfair_lock_unlock(&lock) }
44+
return isMuted
4045
}
4146

4247
func setMuted(_ muted: Bool) {
48+
var fireEnded = false
4349
os_unfair_lock_lock(&lock)
4450
if isMuted != muted {
51+
// Unmuting while speech was latched ends the muted-speech segment.
52+
if !muted, hangoverLatched {
53+
fireEnded = true
54+
}
4555
consecutiveAboveCount = 0
4656
consecutiveBelowCount = 0
4757
hangoverLatched = false
4858
}
4959
isMuted = muted
5060
os_unfair_lock_unlock(&lock)
61+
62+
if fireEnded {
63+
DispatchQueue.main.async { self.onSpeakingWhileMutedChange?(false) }
64+
}
5165
}
5266

5367
func audioProcessingProcess(audioBuffer: LKAudioBuffer) {
@@ -73,37 +87,35 @@ final class SoftwareMuteProcessor: NSObject, @unchecked Sendable, AudioCustomPro
7387

7488
let levelActive = db > mutedSpeechThresholdInDb
7589

76-
var shouldFire = false
77-
var fireLevel: Float = 0
90+
var fireStarted = false
91+
var fireEnded = false
7892
os_unfair_lock_lock(&lock)
7993
if levelActive {
8094
consecutiveBelowCount = 0
8195
consecutiveAboveCount += 1
82-
if consecutiveAboveCount >= Hangover.buffersAboveToConfirm {
96+
if consecutiveAboveCount >= Hangover.buffersAboveToConfirm, !hangoverLatched {
8397
hangoverLatched = true
98+
fireStarted = true
8499
}
85100
} else {
86101
consecutiveAboveCount = 0
87102
consecutiveBelowCount += 1
88-
if consecutiveBelowCount >= Hangover.buffersBelowToClear {
103+
if consecutiveBelowCount >= Hangover.buffersBelowToClear, hangoverLatched {
89104
hangoverLatched = false
90105
consecutiveBelowCount = 0
106+
fireEnded = true
91107
}
92108
}
109+
os_unfair_lock_unlock(&lock)
93110

94-
if hangoverLatched, levelActive {
95-
let now = Date()
96-
if now.timeIntervalSince(lastNotificationTime) > mutedSpeechThrottleInSeconds {
97-
lastNotificationTime = now
98-
shouldFire = true
99-
fireLevel = db
111+
if fireStarted {
112+
DispatchQueue.main.async {
113+
self.onSpeakingWhileMutedChange?(true)
100114
}
101115
}
102-
os_unfair_lock_unlock(&lock)
103-
104-
if shouldFire {
116+
if fireEnded {
105117
DispatchQueue.main.async {
106-
self.onMutedSpeech?(.init(audioLevel: fireLevel))
118+
self.onSpeakingWhileMutedChange?(false)
107119
}
108120
}
109121

0 commit comments

Comments
 (0)