Skip to content

Commit a72eef5

Browse files
renal128cursoragent
andcommitted
Replace ConversationCredentials with ConversationAuth and split start by transport.
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 51f7119 commit a72eef5

23 files changed

Lines changed: 354 additions & 554 deletions

Examples/DemoApp/DemoApp/ContentView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,14 @@ struct ContentView: View {
8989
}
9090
}
9191

92-
private var voiceAuth: WidgetConversationMode.VoiceAuth {
92+
private var voiceAuth: ConversationAuth.Voice {
9393
switch auth {
9494
case .publicAgent: .publicAgent(id: agentId.trimmed)
9595
case .backend: .conversationToken { [token = conversationToken.trimmed] in token }
9696
}
9797
}
9898

99-
private var textOnlyAuth: WidgetConversationMode.TextOnlyAuth {
99+
private var textOnlyAuth: ConversationAuth.TextOnly {
100100
switch auth {
101101
case .publicAgent: .publicAgent(id: agentId.trimmed)
102102
case .backend: .signedWebSocketURL { [url = signedWebSocketURL.trimmed] in url }

Sources/ElevenLabs/Internal/Authorization/TokenService.swift

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,16 @@ struct TokenService: Sendable {
4848
///
4949
/// Translates internal `TokenError`s into public `ConversationError`s so
5050
/// callers only ever deal with one error type.
51-
func fetchToken(for credentials: ConversationCredentials) async throws -> String {
51+
func fetchToken(for auth: ConversationAuth.Voice) async throws -> String {
5252
do {
53-
switch credentials.authSource {
54-
case let .publicAgentId(agentId):
55-
return try await fetchTokenFromAPI(
56-
agentId: agentId,
57-
environment: credentials.environment
58-
)
59-
case let .conversationToken(conversationToken):
60-
return conversationToken
61-
case .signedWebSocketURL:
62-
throw ConversationError.authenticationFailed(
63-
"Signed WebSocket URLs are only supported for text-only conversations."
64-
)
65-
case let .customTokenProvider(provider):
66-
return try await provider()
53+
switch auth {
54+
case let .publicAgent(agentId):
55+
return try await fetchTokenFromAPI(agentId: agentId)
56+
case let .conversationToken(mint):
57+
return try await mint()
6758
}
59+
} catch is CancellationError {
60+
throw CancellationError()
6861
} catch let error as ConversationError {
6962
throw error
7063
} catch let error as TokenError {

Sources/ElevenLabs/Internal/Authorization/TokenServicing.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import Foundation
22

33
protocol TokenServicing: Sendable {
44
/// Resolve the token a voice conversation authenticates with.
5-
/// - Parameter credentials: The credentials to authenticate with
6-
func fetchToken(for credentials: ConversationCredentials) async throws -> String
5+
func fetchToken(for auth: ConversationAuth.Voice) async throws -> String
76
}
87

98
extension TokenService: TokenServicing {}

Sources/ElevenLabs/Internal/Conversation/Conversation.swift

Lines changed: 73 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,6 @@ final class Conversation: ObservableObject {
9393
self.callbacks = callbacks
9494
pendingMuteState = initialMicMuted
9595
logger = dependencyProvider.logger
96-
setupAudioManager()
97-
}
98-
99-
private func setupAudioManager() {
100-
guard !config.conversationOverrides.textOnly else { return }
101-
audioManager = ConversationAudioManager(logger: logger)
10296
}
10397

10498
private func setupAgentStateManager() {
@@ -113,67 +107,36 @@ final class Conversation: ObservableObject {
113107

114108
// MARK: - API
115109

116-
/// Start a conversation using authentication configuration.
117-
func start(auth: ConversationCredentials) async throws -> ConversationStartResult {
118-
guard state == .idle else {
119-
throw ConversationError.alreadyStarted
120-
}
121-
122-
let result: ConversationStartResult = if config.conversationOverrides.textOnly {
123-
try await startTextOnlyConversation(auth: auth)
124-
} else {
125-
try await startVoiceConversation(auth: auth)
126-
}
127-
128-
guard !Task.isCancelled, state.isConnecting else {
129-
await activeConnectionManager?.disconnect()
130-
throw CancellationError()
131-
}
132-
state = .connected(result.callInfo)
133-
callbacks.onAgentReady?()
134-
return result
135-
}
110+
func startVoiceConversation(_ auth: ConversationAuth.Voice) async throws -> ConversationStartResult {
111+
let manager = dependencyProvider.webRTCConnectionManager
112+
let result = try await start(agentId: auth.agentId, isTextOnly: false, using: manager) { config in
113+
let audioManager = ConversationAudioManager(logger: logger)
114+
self.audioManager = audioManager
136115

137-
private func startVoiceConversation(
138-
auth: ConversationCredentials
139-
) async throws -> ConversationStartResult {
140-
let webRTCConnectionManager = dependencyProvider.webRTCConnectionManager
141-
prepareConversationStart(
142-
auth: auth,
143-
connectionManager: webRTCConnectionManager
144-
)
145-
146-
webRTCConnectionManager.onRemoteSpeakingChanged = { [weak self] isSpeaking in
147-
Task { @MainActor in
148-
self?.handleRemoteSpeakingUpdate(isSpeaking: isSpeaking)
116+
manager.onRemoteSpeakingChanged = { [weak self] isSpeaking in
117+
Task { @MainActor in
118+
self?.handleRemoteSpeakingUpdate(isSpeaking: isSpeaking)
119+
}
149120
}
150-
}
151-
webRTCConnectionManager.onTracksChanged = { [weak self] in
152-
Task { @MainActor in
153-
self?.refreshAudioObservers()
121+
manager.onTracksChanged = { [weak self] in
122+
Task { @MainActor in
123+
self?.refreshAudioObservers()
124+
}
125+
}
126+
await audioManager.configure(with: config, callbacks: callbacks)
127+
guard state.isConnecting else {
128+
audioManager.cleanup()
129+
throw CancellationError()
130+
}
131+
if let pendingMuteState {
132+
audioManager.softwareMuteProcessor?.setMuted(pendingMuteState)
154133
}
155-
}
156-
157-
await audioManager?.configure(with: config, callbacks: callbacks)
158-
if let pendingMuteState {
159-
audioManager?.softwareMuteProcessor?.setMuted(pendingMuteState)
160-
}
161134

162-
let result: ConversationStartResult
163-
do {
164-
result = try await webRTCConnectionManager.connect(
135+
return try await manager.connect(
165136
auth: auth,
166137
config: config,
167-
onStartupStateChange: { [weak self] stage in
168-
self?.updateStartupStage(stage)
169-
}
138+
onStartupStateChange: { [weak self] in self?.updateStartupStage($0) }
170139
)
171-
} catch let error as ConversationError {
172-
await handleStartupFailure(error, disconnecting: webRTCConnectionManager)
173-
throw error
174-
} catch is CancellationError {
175-
await handleStartupCancellation(disconnecting: webRTCConnectionManager)
176-
throw CancellationError()
177140
}
178141

179142
if let pendingMute = pendingMuteState {
@@ -182,15 +145,15 @@ final class Conversation: ObservableObject {
182145
if let softwareMuteProcessor = audioManager?.softwareMuteProcessor {
183146
softwareMuteProcessor.setMuted(pendingMute)
184147
} else {
185-
try await webRTCConnectionManager.setMicrophoneMuted(pendingMute)
148+
try await manager.setMicrophoneMuted(pendingMute)
186149
}
187150
} catch {
188151
logger.warning("Failed to apply pending mute state", context: ["error": "\(error)"])
189152
}
190153
}
191154

192155
refreshAudioObservers()
193-
return result
156+
return try await setConnected(result)
194157
}
195158

196159
// MARK: - Audio observers
@@ -224,30 +187,16 @@ final class Conversation: ObservableObject {
224187
micObserverRegistry.attach(to: inputTrack)
225188
}
226189

227-
private func startTextOnlyConversation(
228-
auth: ConversationCredentials
229-
) async throws -> ConversationStartResult {
230-
let connectionManager = dependencyProvider.webSocketConnectionManager
231-
prepareConversationStart(
232-
auth: auth,
233-
connectionManager: connectionManager
234-
)
235-
236-
do {
237-
return try await connectionManager.connect(
190+
func startTextOnlyConversation(_ auth: ConversationAuth.TextOnly) async throws -> ConversationStartResult {
191+
let manager = dependencyProvider.webSocketConnectionManager
192+
let result = try await start(agentId: auth.agentId, isTextOnly: true, using: manager) { config in
193+
try await manager.connect(
238194
auth: auth,
239195
config: config,
240-
onStartupStateChange: { [weak self] stage in
241-
self?.updateStartupStage(stage)
242-
}
196+
onStartupStateChange: { [weak self] in self?.updateStartupStage($0) }
243197
)
244-
} catch let error as ConversationError {
245-
await handleStartupFailure(error, disconnecting: connectionManager)
246-
throw error
247-
} catch is CancellationError {
248-
await handleStartupCancellation(disconnecting: connectionManager)
249-
throw CancellationError()
250198
}
199+
return try await setConnected(result)
251200
}
252201

253202
/// End and clean up.
@@ -375,30 +324,61 @@ final class Conversation: ObservableObject {
375324
state = .connecting(stage)
376325
}
377326

378-
/// Common preparation shared by voice and text-only startup paths.
379-
private func prepareConversationStart(
380-
auth: ConversationCredentials,
381-
connectionManager: any ConnectionManaging
382-
) {
327+
private func start(
328+
agentId: String,
329+
isTextOnly: Bool,
330+
using manager: any ConnectionManaging,
331+
connect: (ConversationConfig) async throws -> ConversationStartResult
332+
) async throws -> ConversationStartResult {
333+
if state != .idle {
334+
if state.isEnded, activeConnectionManager == nil {
335+
throw CancellationError()
336+
}
337+
throw ConversationError.alreadyStarted
338+
}
339+
340+
var startConfig = config
341+
startConfig.conversationOverrides.textOnly = isTextOnly
342+
383343
state = .connecting(.preparing)
384-
activeConnectionManager = connectionManager
344+
activeConnectionManager = manager
385345

386-
activeContext = ["agentId": auth.agentId]
387-
let mode = config.conversationOverrides.textOnly ? "text-only" : "voice"
346+
activeContext = ["agentId": agentId]
347+
let mode = isTextOnly ? "text-only" : "voice"
388348
logger.info("Starting \(mode) conversation", context: activeContext)
389349

390350
setupAgentStateManager()
391351

392-
let connectionManagerID = ObjectIdentifier(connectionManager)
393-
connectionManager.onEventReceived = { [weak self] event in
352+
let connectionManagerID = ObjectIdentifier(manager)
353+
manager.onEventReceived = { [weak self] event in
394354
Task { @MainActor [weak self] in
395355
await self?.handleIncomingEvent(event, from: connectionManagerID)
396356
}
397357
}
398-
connectionManager.onDisconnected = { [weak self] in
358+
manager.onDisconnected = { [weak self] in
399359
guard let self else { return }
400360
await endConversation(reason: .remoteDisconnected)
401361
}
362+
363+
do {
364+
return try await connect(startConfig)
365+
} catch let error as ConversationError {
366+
await handleStartupFailure(error, disconnecting: manager)
367+
throw error
368+
} catch is CancellationError {
369+
await handleStartupCancellation(disconnecting: manager)
370+
throw CancellationError()
371+
}
372+
}
373+
374+
private func setConnected(_ result: ConversationStartResult) async throws -> ConversationStartResult {
375+
guard !Task.isCancelled, state.isConnecting else {
376+
await activeConnectionManager?.disconnect()
377+
throw CancellationError()
378+
}
379+
state = .connected(result.callInfo)
380+
callbacks.onAgentReady?()
381+
return result
402382
}
403383

404384
private func handleIncomingEvent(_ event: IncomingEvent, from connectionManagerID: ObjectIdentifier) async {

Sources/ElevenLabs/Internal/Networking/ConnectionManaging.swift

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,27 @@ protocol ConnectionManaging: AnyObject {
1111
var onDisconnected: (() async -> Void)? { get set }
1212
var errorHandler: ((Swift.Error?) -> Void)? { get set }
1313

14-
@MainActor
15-
func connect(
16-
auth: ConversationCredentials,
17-
config: ConversationConfig,
18-
onStartupStateChange: @escaping (ConversationStartupState) -> Void
19-
) async throws -> ConversationStartResult
20-
2114
func disconnect() async
2215
func send(data: Data) async throws
2316
}
2417

2518
@MainActor
26-
protocol WebSocketConnectionManaging: ConnectionManaging {}
19+
protocol WebSocketConnectionManaging: ConnectionManaging {
20+
func connect(
21+
auth: ConversationAuth.TextOnly,
22+
config: ConversationConfig,
23+
onStartupStateChange: @escaping (ConversationStartupState) -> Void
24+
) async throws -> ConversationStartResult
25+
}
2726

2827
@MainActor
2928
protocol WebRTCConnectionManaging: ConnectionManaging {
29+
func connect(
30+
auth: ConversationAuth.Voice,
31+
config: ConversationConfig,
32+
onStartupStateChange: @escaping (ConversationStartupState) -> Void
33+
) async throws -> ConversationStartResult
34+
3035
var onRemoteSpeakingChanged: (@Sendable (Bool) -> Void)? { get set }
3136
/// Fired when an audio track is published/subscribed/unpublished/unsubscribed.
3237
var onTracksChanged: (@Sendable () -> Void)? { get set }

Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
8383
/// connect room → wait for agent → send init → wait for initiation metadata.
8484
@MainActor
8585
func connect(
86-
auth: ConversationCredentials,
86+
auth: ConversationAuth.Voice,
8787
config: ConversationConfig,
8888
onStartupStateChange: @escaping (ConversationStartupState) -> Void
8989
) async throws -> ConversationStartResult {

0 commit comments

Comments
 (0)