Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ import Foundation

/// Service for managing ElevenLabs authentication
/// This is designed to be stateless and SDK-friendly
public struct TokenService: Sendable {
struct TokenService: Sendable {
private let endpoints: Endpoints
private let urlSession: URLSession

// Development-only API key for testing private agents
// This should only be set in debug builds for local testing
#if DEBUG
public let debugApiKey: String?
let debugApiKey: String?

public init(
init(
endpoints: Endpoints = .production,
urlSession: URLSession = .shared,
debugApiKey: String? = nil
Expand All @@ -35,7 +35,7 @@ public struct TokenService: Sendable {
self.debugApiKey = debugApiKey
}
#else
public init(
init(
endpoints: Endpoints = .production,
urlSession: URLSession = .shared
) {
Expand All @@ -48,7 +48,7 @@ public struct TokenService: Sendable {
///
/// Translates internal `TokenError`s into public `ConversationError`s so
/// callers only ever deal with one error type.
public func fetchToken(for credentials: ConversationCredentials) async throws -> String {
func fetchToken(for credentials: ConversationCredentials) async throws -> String {
do {
switch credentials.authSource {
case let .publicAgentId(agentId):
Expand Down
14 changes: 5 additions & 9 deletions Sources/ElevenLabs/Internal/Conversation/Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,26 +246,22 @@ final class Conversation: ObservableObject {

/// End and clean up.
/// Can be called during connection phase to cancel, or during connected conversation to end.
func endConversation(
disconnectReason: DisconnectionReason = .user,
endReason: EndReason = .userEnded
Comment on lines -250 to -251

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redundant, just one reason is enough

) async {
func endConversation(reason: EndReason = .userEnded) async {
if state == .idle {
state = .ended(reason: endReason)
state = .ended(reason: reason)
tearDownActiveSession()
return
}

guard state.isConnected || state.isConnecting,
let connectionManager = activeConnectionManager
else { return }
state = .ended(reason: endReason)
state = .ended(reason: reason)

tearDownActiveSession()
await connectionManager.disconnect()

// Call user's onDisconnect callback if provided
callbacks.onDisconnect?(disconnectReason)
callbacks.onDisconnect?(reason)
}

/// Send a text message to the agent.
Expand Down Expand Up @@ -402,7 +398,7 @@ final class Conversation: ObservableObject {
}
connectionManager.onDisconnected = { [weak self] in
guard let self else { return }
await endConversation(disconnectReason: .agent, endReason: .remoteDisconnected)
await endConversation(reason: .remoteDisconnected)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
switch await waitForAgentReady(timeout: agentTimeout) {
case let .success(elapsed):
metrics.agentReady = elapsed
onStartupStateChange(.agentReady(ConversationAgentReadyReport(elapsed: elapsed)))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary wrapper class with a single field

onStartupStateChange(.agentReady(elapsed: elapsed))
case let .timedOut(elapsed):
metrics.agentReady = elapsed
metrics.total = Date().timeIntervalSince(startTime)
Expand Down
34 changes: 0 additions & 34 deletions Sources/ElevenLabs/Public/Conversation/AgentState.swift

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public struct ConversationCallbacks: Sendable {
public var onAgentReady: (@Sendable () -> Void)?

/// Called when the agent disconnects or the conversation ends
public var onDisconnect: (@Sendable (DisconnectionReason) -> Void)?
public var onDisconnect: (@Sendable (EndReason) -> Void)?

/// Called when a startup-related error occurs
public var onError: (@Sendable (ConversationError) -> Void)?
Expand Down Expand Up @@ -53,7 +53,7 @@ public struct ConversationCallbacks: Sendable {

public init(
onAgentReady: (@Sendable () -> Void)? = nil,
onDisconnect: (@Sendable (DisconnectionReason) -> Void)? = nil,
onDisconnect: (@Sendable (EndReason) -> Void)? = nil,
onError: (@Sendable (ConversationError) -> Void)? = nil,
onSpeechDetectedWhileMuted: (@Sendable () -> Void)? = nil,
onAgentResponse: (@Sendable (_ text: String, _ eventId: Int) -> Void)? = nil,
Expand Down
123 changes: 116 additions & 7 deletions Sources/ElevenLabs/Public/Conversation/ConversationConfig.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import Foundation

/// Reason for the conversation disconnection
public enum DisconnectionReason: Sendable {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deleted - we already have EndReason for this

case agent
case user
case error
}

/// Main configuration for a conversation session
public struct ConversationConfig: Sendable {
public var agentOverrides: AgentOverrides?
Expand Down Expand Up @@ -152,3 +145,119 @@ public struct Endpoints: Sendable, Equatable {
apiBase.appendingPathComponent("v1/convai/conversation/token")
}
}

public struct ConversationStartupConfiguration: Sendable, Equatable {
public var agentReadyTimeout: TimeInterval
public var initiationMetadataTimeout: TimeInterval

public init(
agentReadyTimeout: TimeInterval = 3.0,
initiationMetadataTimeout: TimeInterval = 5.0
) {
self.agentReadyTimeout = agentReadyTimeout
self.initiationMetadataTimeout = initiationMetadataTimeout
}

public static let `default` = ConversationStartupConfiguration()
}

/// Controls how the SDK establishes WebRTC connections.
///
/// The default configuration gathers all ICE candidate types. Use ``Strategy/relayOnly``
/// to restrict connections to TURN relays.
public struct WebRTCConfiguration: Sendable {
/// Describes how ICE transport candidates should be gathered.
public enum Strategy: Sendable, Equatable {
/// Gather all candidate types.
case automatic
/// Force TURN relay candidates only.
case relayOnly
}

/// The strategy to use for ICE gathering. Defaults to ``Strategy/automatic``.
public var strategy: Strategy

public init(strategy: Strategy = .automatic) {
self.strategy = strategy
}

/// Default configuration using automatic ICE candidate gathering.
public static let `default` = WebRTCConfiguration()
}

/// Configuration for event-based agent state management using VAD and client events.
/// Pass `nil` to use the default LiveKit-based behaviour.
public struct AgentStateConfiguration: Sendable {
public var vadSpeakingThreshold: Double
public var minSpeechDuration: TimeInterval
public var minSilenceDuration: TimeInterval
public var speakingToListeningDelay: TimeInterval

public init(
vadSpeakingThreshold: Double = 0.5,
minSpeechDuration: TimeInterval = 0.15,
minSilenceDuration: TimeInterval = 0.05,
speakingToListeningDelay: TimeInterval = 0.5
) {
self.vadSpeakingThreshold = vadSpeakingThreshold
self.minSpeechDuration = minSpeechDuration
self.minSilenceDuration = minSilenceDuration
self.speakingToListeningDelay = speakingToListeningDelay
}

public static let `default` = AgentStateConfiguration()
}

/// Configures microphone pipeline and voice activity reporting exposed by the SDK.
public struct AudioPipelineConfiguration: Sendable {
/// Override the microphone mute strategy. Defaults to `.inputMixer` to match previous SDK behaviour.
public var microphoneMuteMode: MicrophoneMuteMode?

/// Keep the recording engine warm to avoid first-spoken-word latency. Defaults to `true`.
public var recordingAlwaysPrepared: Bool?

/// Bypass WebRTC voice processing (AEC/NS/VAD). Leave `nil` to preserve system defaults.
public var voiceProcessingBypassed: Bool?

/// Toggle Auto Gain Control. Leave `nil` to preserve system defaults.
public var voiceProcessingAGCEnabled: Bool?

public init(
microphoneMuteMode: MicrophoneMuteMode? = .inputMixer,
recordingAlwaysPrepared: Bool? = true,
voiceProcessingBypassed: Bool? = nil,
voiceProcessingAGCEnabled: Bool? = nil
) {
self.microphoneMuteMode = microphoneMuteMode
self.recordingAlwaysPrepared = recordingAlwaysPrepared
self.voiceProcessingBypassed = voiceProcessingBypassed
self.voiceProcessingAGCEnabled = voiceProcessingAGCEnabled
}

public static let `default` = AudioPipelineConfiguration()
}

/// Strategy used when muting the local microphone. Exactly one strategy is active
/// at a time.
public enum MicrophoneMuteMode: Sendable, Equatable {
/// Mutes instantly by silencing the input mixer. The mic stays open and the
/// audio session remains active. Recommended default.
case inputMixer

/// Mutes by restarting the engine without mic input. Releases the mic, but
/// mute/unmute is slower and speech detection is unavailable.
case restart

/// Mutes the voice-processing input. Fast, supports
/// ``ConversationCallbacks/onSpeechDetectedWhileMuted``, and keeps the audio
/// session active.
case voiceProcessing

/// Mutes in software by zeroing captured audio before it leaves the device.
/// Supports ``ConversationCallbacks/onSpeechDetectedWhileMuted``.
///
/// - Parameters:
/// - speechThreshold: dB threshold for muted-speech detection.
/// - notificationThrottle: Minimum interval between muted-speech callbacks.
case software(speechThreshold: Float = -35, notificationThrottle: TimeInterval = 3.0)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import Foundation

/// In-flight startup stage while `ConversationState` is `.connecting`.
public enum ConversationStartupState: Sendable, Equatable {
/// Session teardown / wiring before transport-specific stages begin.
case preparing
case resolvingToken
case connectingRoom
case waitingForAgent(timeout: TimeInterval)
case agentReady(elapsed: TimeInterval)
case sendingConversationInit
case waitingForInitiationMetadata(timeout: TimeInterval)
}

public struct ConversationStartupMetrics: Sendable, Equatable {
public var total: TimeInterval?
public var tokenFetch: TimeInterval?
Expand Down Expand Up @@ -30,3 +42,8 @@ public struct ConversationStartupMetrics: Sendable, Equatable {
self.initiationMetadata = initiationMetadata
}
}

public struct ConversationStartResult: Equatable, Sendable {
public let callInfo: CallInfo
public let metrics: ConversationStartupMetrics
}
10 changes: 10 additions & 0 deletions Sources/ElevenLabs/Public/Conversation/ConversationState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,13 @@ public enum ConversationState: Equatable, Sendable {
return nil
}
}

public struct CallInfo: Equatable, Sendable {
public let agentId: String
public let conversationId: String
}

public enum EndReason: Equatable, Sendable {
case userEnded
case remoteDisconnected
}
11 changes: 11 additions & 0 deletions Sources/ElevenLabs/Public/Conversation/Models/AgentState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

/// Agent state indicating what the agent is currently doing.
public enum AgentState: Sendable, Equatable {
/// Agent is listening to the user
case listening
/// Agent is speaking
case speaking
/// Agent is thinking (e.g. preparing a tool call)
case thinking
}
Loading
Loading