Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Examples/DemoApp/DemoApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,14 @@ struct ContentView: View {
}
}

private var voiceAuth: WidgetConversationMode.VoiceAuth {
private var voiceAuth: ConversationAuth.Voice {
switch auth {
case .publicAgent: .publicAgent(id: agentId.trimmed)
case .backend: .conversationToken { [token = conversationToken.trimmed] in token }
}
}

private var textOnlyAuth: WidgetConversationMode.TextOnlyAuth {
private var textOnlyAuth: ConversationAuth.TextOnly {
switch auth {
case .publicAgent: .publicAgent(id: agentId.trimmed)
case .backend: .signedWebSocketURL { [url = signedWebSocketURL.trimmed] in url }
Expand Down
23 changes: 8 additions & 15 deletions Sources/ElevenLabs/Internal/Authorization/TokenService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,23 +48,16 @@ struct TokenService: Sendable {
///
/// Translates internal `TokenError`s into public `ConversationError`s so
/// callers only ever deal with one error type.
func fetchToken(for credentials: ConversationCredentials) async throws -> String {
func fetchToken(for auth: ConversationAuth.Voice) async throws -> String {
do {
switch credentials.authSource {
case let .publicAgentId(agentId):
return try await fetchTokenFromAPI(
agentId: agentId,
environment: credentials.environment
)
case let .conversationToken(conversationToken):
return conversationToken
case .signedWebSocketURL:
throw ConversationError.authenticationFailed(
"Signed WebSocket URLs are only supported for text-only conversations."
)
case let .customTokenProvider(provider):
Comment thread
renal128 marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
return try await provider()
switch auth {
case let .publicAgent(agentId):
return try await fetchTokenFromAPI(agentId: agentId)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
case let .conversationToken(mint):
return try await mint()
}
} catch is CancellationError {
throw CancellationError()
} catch let error as ConversationError {
throw error
} catch let error as TokenError {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import Foundation

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

extension TokenService: TokenServicing {}
166 changes: 73 additions & 93 deletions Sources/ElevenLabs/Internal/Conversation/Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,6 @@ final class Conversation: ObservableObject {
self.callbacks = callbacks
pendingMuteState = initialMicMuted
logger = dependencyProvider.logger
setupAudioManager()
}

private func setupAudioManager() {
guard !config.conversationOverrides.textOnly else { return }
audioManager = ConversationAudioManager(logger: logger)
}

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

// MARK: - API

/// Start a conversation using authentication configuration.
func start(auth: ConversationCredentials) async throws -> ConversationStartResult {
guard state == .idle else {
throw ConversationError.alreadyStarted
}

let result: ConversationStartResult = if config.conversationOverrides.textOnly {
try await startTextOnlyConversation(auth: auth)
} else {
try await startVoiceConversation(auth: auth)
}

guard !Task.isCancelled, state.isConnecting else {
await activeConnectionManager?.disconnect()
throw CancellationError()
}
state = .connected(result.callInfo)
callbacks.onAgentReady?()
return result
}
func startVoiceConversation(_ auth: ConversationAuth.Voice) async throws -> ConversationStartResult {
let manager = dependencyProvider.webRTCConnectionManager
let result = try await start(agentId: auth.agentId, isTextOnly: false, using: manager) { config in
let audioManager = ConversationAudioManager(logger: logger)
self.audioManager = audioManager

private func startVoiceConversation(
auth: ConversationCredentials
) async throws -> ConversationStartResult {
let webRTCConnectionManager = dependencyProvider.webRTCConnectionManager
prepareConversationStart(
auth: auth,
connectionManager: webRTCConnectionManager
)

webRTCConnectionManager.onRemoteSpeakingChanged = { [weak self] isSpeaking in
Task { @MainActor in
self?.handleRemoteSpeakingUpdate(isSpeaking: isSpeaking)
manager.onRemoteSpeakingChanged = { [weak self] isSpeaking in
Task { @MainActor in
self?.handleRemoteSpeakingUpdate(isSpeaking: isSpeaking)
}
}
}
webRTCConnectionManager.onTracksChanged = { [weak self] in
Task { @MainActor in
self?.refreshAudioObservers()
manager.onTracksChanged = { [weak self] in
Task { @MainActor in
self?.refreshAudioObservers()
}
}
await audioManager.configure(with: config, callbacks: callbacks)
guard state.isConnecting else {
audioManager.cleanup()
throw CancellationError()
}
if let pendingMuteState {
audioManager.softwareMuteProcessor?.setMuted(pendingMuteState)
}
}

await audioManager?.configure(with: config, callbacks: callbacks)
if let pendingMuteState {
audioManager?.softwareMuteProcessor?.setMuted(pendingMuteState)
}

let result: ConversationStartResult
do {
result = try await webRTCConnectionManager.connect(
return try await manager.connect(
auth: auth,
config: config,
onStartupStateChange: { [weak self] stage in
self?.updateStartupStage(stage)
}
onStartupStateChange: { [weak self] in self?.updateStartupStage($0) }
)
} catch let error as ConversationError {
await handleStartupFailure(error, disconnecting: webRTCConnectionManager)
throw error
} catch is CancellationError {
await handleStartupCancellation(disconnecting: webRTCConnectionManager)
throw CancellationError()
}

if let pendingMute = pendingMuteState {
Expand All @@ -182,15 +145,15 @@ final class Conversation: ObservableObject {
if let softwareMuteProcessor = audioManager?.softwareMuteProcessor {
softwareMuteProcessor.setMuted(pendingMute)
} else {
try await webRTCConnectionManager.setMicrophoneMuted(pendingMute)
try await manager.setMicrophoneMuted(pendingMute)
}
} catch {
logger.warning("Failed to apply pending mute state", context: ["error": "\(error)"])
}
}

refreshAudioObservers()
return result
return try await setConnected(result)
Comment thread
cursor[bot] marked this conversation as resolved.
}

// MARK: - Audio observers
Expand Down Expand Up @@ -224,30 +187,16 @@ final class Conversation: ObservableObject {
micObserverRegistry.attach(to: inputTrack)
}

private func startTextOnlyConversation(
auth: ConversationCredentials
) async throws -> ConversationStartResult {
let connectionManager = dependencyProvider.webSocketConnectionManager
prepareConversationStart(
auth: auth,
connectionManager: connectionManager
)

do {
return try await connectionManager.connect(
func startTextOnlyConversation(_ auth: ConversationAuth.TextOnly) async throws -> ConversationStartResult {
let manager = dependencyProvider.webSocketConnectionManager
let result = try await start(agentId: auth.agentId, isTextOnly: true, using: manager) { config in
try await manager.connect(
auth: auth,
config: config,
onStartupStateChange: { [weak self] stage in
self?.updateStartupStage(stage)
}
onStartupStateChange: { [weak self] in self?.updateStartupStage($0) }
)
} catch let error as ConversationError {
await handleStartupFailure(error, disconnecting: connectionManager)
throw error
} catch is CancellationError {
await handleStartupCancellation(disconnecting: connectionManager)
throw CancellationError()
}
return try await setConnected(result)
}

/// End and clean up.
Expand Down Expand Up @@ -375,30 +324,61 @@ final class Conversation: ObservableObject {
state = .connecting(stage)
}

/// Common preparation shared by voice and text-only startup paths.
private func prepareConversationStart(
auth: ConversationCredentials,
connectionManager: any ConnectionManaging
) {
private func start(
agentId: String,
isTextOnly: Bool,
using manager: any ConnectionManaging,
connect: (ConversationConfig) async throws -> ConversationStartResult
) async throws -> ConversationStartResult {
if state != .idle {
if state.isEnded, activeConnectionManager == nil {
throw CancellationError()
}
throw ConversationError.alreadyStarted
}

var startConfig = config
startConfig.conversationOverrides.textOnly = isTextOnly

state = .connecting(.preparing)
activeConnectionManager = connectionManager
activeConnectionManager = manager

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

setupAgentStateManager()

let connectionManagerID = ObjectIdentifier(connectionManager)
connectionManager.onEventReceived = { [weak self] event in
let connectionManagerID = ObjectIdentifier(manager)
manager.onEventReceived = { [weak self] event in
Task { @MainActor [weak self] in
await self?.handleIncomingEvent(event, from: connectionManagerID)
}
}
connectionManager.onDisconnected = { [weak self] in
manager.onDisconnected = { [weak self] in
guard let self else { return }
await endConversation(reason: .remoteDisconnected)
}

do {
return try await connect(startConfig)
} catch let error as ConversationError {
await handleStartupFailure(error, disconnecting: manager)
throw error
} catch is CancellationError {
await handleStartupCancellation(disconnecting: manager)
throw CancellationError()
}
}

private func setConnected(_ result: ConversationStartResult) async throws -> ConversationStartResult {
guard !Task.isCancelled, state.isConnecting else {
await activeConnectionManager?.disconnect()
throw CancellationError()
}
state = .connected(result.callInfo)
callbacks.onAgentReady?()
return result
}

private func handleIncomingEvent(_ event: IncomingEvent, from connectionManagerID: ObjectIdentifier) async {
Expand Down
21 changes: 13 additions & 8 deletions Sources/ElevenLabs/Internal/Networking/ConnectionManaging.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@ protocol ConnectionManaging: AnyObject {
var onDisconnected: (() async -> Void)? { get set }
var errorHandler: ((Swift.Error?) -> Void)? { get set }

@MainActor
func connect(
auth: ConversationCredentials,
config: ConversationConfig,
onStartupStateChange: @escaping (ConversationStartupState) -> Void
) async throws -> ConversationStartResult

func disconnect() async
func send(data: Data) async throws
}

@MainActor
protocol WebSocketConnectionManaging: ConnectionManaging {}
protocol WebSocketConnectionManaging: ConnectionManaging {
func connect(
auth: ConversationAuth.TextOnly,
config: ConversationConfig,
onStartupStateChange: @escaping (ConversationStartupState) -> Void
) async throws -> ConversationStartResult
}

@MainActor
protocol WebRTCConnectionManaging: ConnectionManaging {
func connect(
auth: ConversationAuth.Voice,
config: ConversationConfig,
onStartupStateChange: @escaping (ConversationStartupState) -> Void
) async throws -> ConversationStartResult

var onRemoteSpeakingChanged: (@Sendable (Bool) -> Void)? { get set }
/// Fired when an audio track is published/subscribed/unpublished/unsubscribed.
var onTracksChanged: (@Sendable () -> Void)? { get set }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
/// connect room → wait for agent → send init → wait for initiation metadata.
@MainActor
func connect(
auth: ConversationCredentials,
auth: ConversationAuth.Voice,
config: ConversationConfig,
onStartupStateChange: @escaping (ConversationStartupState) -> Void
) async throws -> ConversationStartResult {
Expand Down
Loading
Loading