Skip to content

Commit 643837d

Browse files
renal128cursoragent
andcommitted
Keep voice-only setup in startVoiceConversation and share one start path.
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 64020ee commit 643837d

2 files changed

Lines changed: 118 additions & 160 deletions

File tree

Sources/ElevenLabs/Internal/Conversation/Conversation.swift

Lines changed: 111 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@ import LiveKit
66

77
/// A single-use conversation session, created by and owned by `ConversationClient`.
88
///
9-
/// Manages the lifecycle of one conversation: network layer
10-
/// (`WebRTCConnectionManager`|`WebSocketConnectionManager`), protocol parser
11-
/// (`EventParser`), and audio device setup.
9+
/// Owns one conversation's transport (`WebRTCConnectionManager` or
10+
/// `WebSocketConnectionManager`) and audio device setup.
1211
@MainActor
1312
final class Conversation: ObservableObject {
1413
// MARK: - State
@@ -17,8 +16,6 @@ final class Conversation: ObservableObject {
1716
@Published var chatHistory: [any ChatHistoryItem] = []
1817
@Published var agentState: AgentState = .listening
1918

20-
private var chatHistoryReconciler = ChatHistoryReconciler()
21-
2219
/// Stream of client tool calls that need to be executed by the app
2320
@Published var pendingToolCalls: [ClientToolCallEvent] = []
2421

@@ -31,38 +28,29 @@ final class Conversation: ObservableObject {
3128
/// Current MCP connection status for all integrations
3229
@Published var mcpConnectionStatus: MCPConnectionStatusEvent?
3330

34-
/// Pending mute state to apply after connection completes.
35-
/// Allows setting mute state during connection phase.
36-
private var pendingMuteState: Bool?
31+
private var chatHistoryReconciler = ChatHistoryReconciler()
32+
33+
/// Agent state manager for event-based state tracking
34+
private var agentStateManager: AgentStateManager?
3735

3836
/// Audio device management
3937
private var audioManager: ConversationAudioManager?
4038

39+
/// Pending mute state to apply after connection completes.
40+
/// Allows setting mute state during connection phase.
41+
private var pendingMuteState: Bool?
42+
4143
/// Externally registered audio observers. Kept attached across track swaps.
4244
let agentObserverRegistry = AudioObserverRegistry()
4345
let micObserverRegistry = AudioObserverRegistry()
4446

45-
/// Agent state manager for event-based state tracking
46-
var agentStateManager: AgentStateManager?
47-
48-
/// Forward a signal to the event-based state manager, or fall back to directly setting `agentState`.
49-
func applyStateSignal(_ signal: AgentStateSignal, fallback: AgentState) {
50-
if let manager = agentStateManager {
51-
manager.processSignal(signal)
52-
} else {
53-
agentState = fallback
54-
}
47+
/// Internal LiveKit tracks used to attach ``ConversationAudioObserver``s.
48+
private var inputTrack: (any AudioTrackProtocol)? {
49+
activeWebRTCConnectionManager?.inputTrack
5550
}
5651

57-
func handleRemoteSpeakingUpdate(isSpeaking: Bool) {
58-
if let manager = agentStateManager {
59-
manager.processSignal(isSpeaking ? .agentStartedSpeaking : .agentStoppedSpeaking)
60-
} else if isSpeaking {
61-
speakingTimer?.cancel()
62-
agentState = .speaking
63-
} else {
64-
scheduleBackToListening(delay: 1.0)
65-
}
52+
private var agentAudioTrack: (any AudioTrackProtocol)? {
53+
activeWebRTCConnectionManager?.agentAudioTrack
6654
}
6755

6856
/// Internal logger, accessible from nonisolated contexts.
@@ -71,14 +59,10 @@ final class Conversation: ObservableObject {
7159
/// Context for logging (e.g. agentId)
7260
private var activeContext: [String: String]?
7361

74-
/// Internal LiveKit tracks used to attach ``ConversationAudioObserver``s.
75-
var inputTrack: (any AudioTrackProtocol)? {
76-
activeWebRTCConnectionManager?.inputTrack
77-
}
78-
79-
var agentAudioTrack: (any AudioTrackProtocol)? {
80-
activeWebRTCConnectionManager?.agentAudioTrack
81-
}
62+
let config: ConversationConfig
63+
let callbacks: ConversationCallbacks
64+
private var speakingTimer: Task<Void, Never>?
65+
private var isTearingDown = false
8266

8367
// MARK: - Init
8468

@@ -95,115 +79,63 @@ final class Conversation: ObservableObject {
9579
logger = dependencyProvider.logger
9680
}
9781

98-
private func setupAudioManager() {
99-
audioManager = ConversationAudioManager(logger: logger)
100-
}
101-
102-
private func setupAgentStateManager() {
103-
guard let configuration = config.agentStateConfiguration else { return }
104-
let manager = AgentStateManager(configuration: configuration)
105-
manager.onStateChange = { [weak self] state in
106-
self?.agentState = state
107-
self?.callbacks.onAgentStateChange?(state)
108-
}
109-
agentStateManager = manager
110-
}
111-
11282
// MARK: - API
11383

11484
func startVoiceConversation(_ auth: ConversationAuth.Voice) async throws -> ConversationStartResult {
115-
try await runStart(isTextOnly: false) { config in
116-
try await performVoiceStart(auth, config: config)
117-
}
118-
}
119-
120-
func startTextConversation(_ auth: ConversationAuth.TextOnly) async throws -> ConversationStartResult {
121-
try await runStart(isTextOnly: true) { config in
122-
try await performTextStart(auth, config: config)
123-
}
124-
}
85+
let manager = dependencyProvider.webRTCConnectionManager
86+
let audioManager = ConversationAudioManager(logger: logger)
87+
self.audioManager = audioManager
12588

126-
private func runStart(
127-
isTextOnly: Bool,
128-
_ work: (ConversationConfig) async throws -> ConversationStartResult
129-
) async throws -> ConversationStartResult {
130-
guard state == .idle else {
131-
throw ConversationError.alreadyStarted
132-
}
133-
134-
var startConfig = config
135-
startConfig.conversationOverrides.textOnly = isTextOnly
136-
let result = try await work(startConfig)
137-
138-
guard !Task.isCancelled, state.isConnecting else {
139-
await activeConnectionManager?.disconnect()
140-
throw CancellationError()
141-
}
142-
state = .connected(result.callInfo)
143-
callbacks.onAgentReady?()
144-
return result
145-
}
146-
147-
private func performVoiceStart(
148-
_ auth: ConversationAuth.Voice,
149-
config: ConversationConfig
150-
) async throws -> ConversationStartResult {
151-
let webRTCConnectionManager = dependencyProvider.webRTCConnectionManager
152-
setupAudioManager()
153-
prepareConversationStart(
154-
agentId: auth.agentId,
155-
isTextOnly: false,
156-
connectionManager: webRTCConnectionManager
157-
)
158-
159-
webRTCConnectionManager.onRemoteSpeakingChanged = { [weak self] isSpeaking in
89+
manager.onRemoteSpeakingChanged = { [weak self] isSpeaking in
16090
Task { @MainActor in
16191
self?.handleRemoteSpeakingUpdate(isSpeaking: isSpeaking)
16292
}
16393
}
164-
webRTCConnectionManager.onTracksChanged = { [weak self] in
94+
manager.onTracksChanged = { [weak self] in
16595
Task { @MainActor in
16696
self?.refreshAudioObservers()
16797
}
16898
}
169-
170-
await audioManager?.configure(with: config, callbacks: callbacks)
99+
await audioManager.configure(with: config, callbacks: callbacks)
171100
if let pendingMuteState {
172-
audioManager?.softwareMuteProcessor?.setMuted(pendingMuteState)
101+
audioManager.softwareMuteProcessor?.setMuted(pendingMuteState)
173102
}
174103

175-
let result: ConversationStartResult
176-
do {
177-
result = try await webRTCConnectionManager.connect(
104+
let result = try await start(agentId: auth.agentId, isTextOnly: false, using: manager) { config in
105+
try await manager.connect(
178106
auth: auth,
179107
config: config,
180-
onStartupStateChange: { [weak self] stage in
181-
self?.updateStartupStage(stage)
182-
}
108+
onStartupStateChange: { [weak self] in self?.updateStartupStage($0) }
183109
)
184-
} catch let error as ConversationError {
185-
await handleStartupFailure(error, disconnecting: webRTCConnectionManager)
186-
throw error
187-
} catch is CancellationError {
188-
await handleStartupCancellation(disconnecting: webRTCConnectionManager)
189-
throw CancellationError()
190110
}
191111

192112
if let pendingMute = pendingMuteState {
193113
pendingMuteState = nil
194114
do {
195-
if let softwareMuteProcessor = audioManager?.softwareMuteProcessor {
115+
if let softwareMuteProcessor = audioManager.softwareMuteProcessor {
196116
softwareMuteProcessor.setMuted(pendingMute)
197117
} else {
198-
try await webRTCConnectionManager.setMicrophoneMuted(pendingMute)
118+
try await manager.setMicrophoneMuted(pendingMute)
199119
}
200120
} catch {
201121
logger.warning("Failed to apply pending mute state", context: ["error": "\(error)"])
202122
}
203123
}
204124

205125
refreshAudioObservers()
206-
return result
126+
return try await setConnected(result)
127+
}
128+
129+
func startTextConversation(_ auth: ConversationAuth.TextOnly) async throws -> ConversationStartResult {
130+
let manager = dependencyProvider.webSocketConnectionManager
131+
let result = try await start(agentId: auth.agentId, isTextOnly: true, using: manager) { config in
132+
try await manager.connect(
133+
auth: auth,
134+
config: config,
135+
onStartupStateChange: { [weak self] in self?.updateStartupStage($0) }
136+
)
137+
}
138+
return try await setConnected(result)
207139
}
208140

209141
// MARK: - Audio observers
@@ -237,38 +169,6 @@ final class Conversation: ObservableObject {
237169
micObserverRegistry.attach(to: inputTrack)
238170
}
239171

240-
private func performTextStart(
241-
_ auth: ConversationAuth.TextOnly,
242-
config: ConversationConfig
243-
) async throws -> ConversationStartResult {
244-
let connectionManager = dependencyProvider.webSocketConnectionManager
245-
let agentId = switch auth {
246-
case let .publicAgent(id): id
247-
case .signedWebSocketURL: "unknown"
248-
}
249-
prepareConversationStart(
250-
agentId: agentId,
251-
isTextOnly: true,
252-
connectionManager: connectionManager
253-
)
254-
255-
do {
256-
return try await connectionManager.connect(
257-
auth: auth,
258-
config: config,
259-
onStartupStateChange: { [weak self] stage in
260-
self?.updateStartupStage(stage)
261-
}
262-
)
263-
} catch let error as ConversationError {
264-
await handleStartupFailure(error, disconnecting: connectionManager)
265-
throw error
266-
} catch is CancellationError {
267-
await handleStartupCancellation(disconnecting: connectionManager)
268-
throw CancellationError()
269-
}
270-
}
271-
272172
/// End and clean up.
273173
/// Can be called during connection phase to cancel, or during connected conversation to end.
274174
func endConversation(reason: EndReason = .userEnded) async {
@@ -336,7 +236,7 @@ final class Conversation: ObservableObject {
336236
try await publish(event)
337237
}
338238

339-
/// Contextual update to agent (system prompt-ish).
239+
/// Inject context for the agent without interrupting or adding a user-visible message.
340240
func updateContext(_ context: String) async throws {
341241
guard state.isConnected else { throw ConversationError.notConnected }
342242
let event = OutgoingEvent.contextualUpdate(ContextualUpdateEvent(text: context))
@@ -383,42 +283,93 @@ final class Conversation: ObservableObject {
383283
activeConnectionManager as? any WebRTCConnectionManaging
384284
}
385285

386-
let config: ConversationConfig
387-
let callbacks: ConversationCallbacks
388-
389-
var speakingTimer: Task<Void, Never>?
390-
private var isTearingDown = false
391-
392286
private func updateStartupStage(_ stage: ConversationStartupState) {
393287
guard state.isConnecting, state != .connecting(stage) else { return }
394288
state = .connecting(stage)
395289
}
396290

397-
/// Common preparation shared by voice and text-only startup paths.
398-
private func prepareConversationStart(
291+
private func setupAgentStateManager() {
292+
guard let configuration = config.agentStateConfiguration else { return }
293+
let manager = AgentStateManager(configuration: configuration)
294+
manager.onStateChange = { [weak self] state in
295+
self?.agentState = state
296+
self?.callbacks.onAgentStateChange?(state)
297+
}
298+
agentStateManager = manager
299+
}
300+
301+
/// Forward a signal to the event-based state manager, or fall back to directly setting `agentState`.
302+
private func applyStateSignal(_ signal: AgentStateSignal, fallback: AgentState) {
303+
if let manager = agentStateManager {
304+
manager.processSignal(signal)
305+
} else {
306+
agentState = fallback
307+
}
308+
}
309+
310+
private func handleRemoteSpeakingUpdate(isSpeaking: Bool) {
311+
if let manager = agentStateManager {
312+
manager.processSignal(isSpeaking ? .agentStartedSpeaking : .agentStoppedSpeaking)
313+
} else if isSpeaking {
314+
speakingTimer?.cancel()
315+
agentState = .speaking
316+
} else {
317+
scheduleBackToListening(delay: 1.0)
318+
}
319+
}
320+
321+
private func start(
399322
agentId: String,
400323
isTextOnly: Bool,
401-
connectionManager: any ConnectionManaging
402-
) {
324+
using manager: any ConnectionManaging,
325+
connect: (ConversationConfig) async throws -> ConversationStartResult
326+
) async throws -> ConversationStartResult {
327+
guard state == .idle else {
328+
throw ConversationError.alreadyStarted
329+
}
330+
331+
var startConfig = config
332+
startConfig.conversationOverrides.textOnly = isTextOnly
333+
403334
state = .connecting(.preparing)
404-
activeConnectionManager = connectionManager
335+
activeConnectionManager = manager
405336

406337
activeContext = ["agentId": agentId]
407338
let mode = isTextOnly ? "text-only" : "voice"
408339
logger.info("Starting \(mode) conversation", context: activeContext)
409340

410341
setupAgentStateManager()
411342

412-
let connectionManagerID = ObjectIdentifier(connectionManager)
413-
connectionManager.onEventReceived = { [weak self] event in
343+
let connectionManagerID = ObjectIdentifier(manager)
344+
manager.onEventReceived = { [weak self] event in
414345
Task { @MainActor [weak self] in
415346
await self?.handleIncomingEvent(event, from: connectionManagerID)
416347
}
417348
}
418-
connectionManager.onDisconnected = { [weak self] in
349+
manager.onDisconnected = { [weak self] in
419350
guard let self else { return }
420351
await endConversation(reason: .remoteDisconnected)
421352
}
353+
354+
do {
355+
return try await connect(startConfig)
356+
} catch let error as ConversationError {
357+
await handleStartupFailure(error, disconnecting: manager)
358+
throw error
359+
} catch is CancellationError {
360+
await handleStartupCancellation(disconnecting: manager)
361+
throw CancellationError()
362+
}
363+
}
364+
365+
private func setConnected(_ result: ConversationStartResult) async throws -> ConversationStartResult {
366+
guard !Task.isCancelled, state.isConnecting else {
367+
await activeConnectionManager?.disconnect()
368+
throw CancellationError()
369+
}
370+
state = .connected(result.callInfo)
371+
callbacks.onAgentReady?()
372+
return result
422373
}
423374

424375
private func handleIncomingEvent(_ event: IncomingEvent, from connectionManagerID: ObjectIdentifier) async {
@@ -488,7 +439,7 @@ final class Conversation: ObservableObject {
488439
}
489440
}
490441

491-
func publish(_ event: OutgoingEvent) async throws {
442+
private func publish(_ event: OutgoingEvent) async throws {
492443
guard let connectionManager = activeConnectionManager else {
493444
throw ConversationError.notConnected
494445
}

0 commit comments

Comments
 (0)