Skip to content

Commit eff4f67

Browse files
committed
Enable Swift 6 checks
1 parent 8dadd6a commit eff4f67

20 files changed

Lines changed: 191 additions & 158 deletions

.github/workflows/ci.yml

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ on:
44
push:
55
branches:
66
- main
7+
- next
78
pull_request:
89
branches:
910
- main
11+
- next
1012

1113
permissions:
1214
contents: read
@@ -52,36 +54,33 @@ jobs:
5254
- name: Test (release)
5355
run: swift test --configuration release
5456

55-
# Swift 6 strict build temporarily disabled because LiveKit pins JWTKit to
56-
# 4.13.x, which lacks Sendable annotations. Re-enable after upstream fixes.
57-
# Tracking: https://github.com/livekit/client-sdk-swift/issues/846
58-
# swift6-strict:
59-
# runs-on: macos-15
60-
# timeout-minutes: 30
61-
# needs: build
62-
# env:
63-
# SWIFT_STRICT_FLAGS: "-Xswiftc -swift-version -Xswiftc 6"
64-
#
65-
# steps:
66-
# - name: Checkout
67-
# uses: actions/checkout@v3
68-
#
69-
# - name: Set up Xcode 16.4
70-
# run: sudo xcode-select -s /Applications/Xcode_16.4.0.app
71-
#
72-
# - name: Cache SwiftPM dependencies (Swift 6 strict)
73-
# uses: actions/cache@v4
74-
# with:
75-
# path: .build
76-
# key: ${{ runner.os }}-spm-strict-${{ hashFiles('Package.resolved') }}
77-
# restore-keys: |
78-
# ${{ runner.os }}-spm-
79-
#
80-
# - name: Resolve dependencies
81-
# run: swift package resolve
82-
#
83-
# - name: Build (Swift 6 strict)
84-
# run: swift build $SWIFT_STRICT_FLAGS
85-
#
86-
# - name: Test (Swift 6 strict)
87-
# run: swift test $SWIFT_STRICT_FLAGS
57+
swift6-strict:
58+
runs-on: macos-15
59+
timeout-minutes: 30
60+
needs: build
61+
env:
62+
SWIFT_STRICT_FLAGS: "-Xswiftc -swift-version -Xswiftc 6"
63+
64+
steps:
65+
- name: Checkout
66+
uses: actions/checkout@v3
67+
68+
- name: Set up Xcode 16.4
69+
run: sudo xcode-select -s /Applications/Xcode_16.4.0.app
70+
71+
- name: Cache SwiftPM dependencies (Swift 6 strict)
72+
uses: actions/cache@v4
73+
with:
74+
path: .build
75+
key: ${{ runner.os }}-spm-strict-${{ hashFiles('Package.resolved') }}
76+
restore-keys: |
77+
${{ runner.os }}-spm-
78+
79+
- name: Resolve dependencies
80+
run: swift package resolve
81+
82+
- name: Build (Swift 6 strict)
83+
run: swift build $SWIFT_STRICT_FLAGS
84+
85+
- name: Test (Swift 6 strict)
86+
run: swift test $SWIFT_STRICT_FLAGS

Package.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ let package = Package(
3131
dependencies: [
3232
.product(name: "LiveKit", package: "client-sdk-swift")
3333
],
34-
// TODO: Re-enable StrictConcurrency once LiveKit depends on a JWTKit
35-
// release where Sendable annotations are available (4.13.x patch or 5.x).
36-
// Tracking: https://github.com/livekit/client-sdk-swift/issues/846
3734
exclude: [
3835
"Internal/Protocol/schemas/agent.asyncapi.yaml"
3936
],

Package@swift-6.0.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ let package = Package(
3131
dependencies: [
3232
.product(name: "LiveKit", package: "client-sdk-swift")
3333
],
34-
// TODO: Re-enable StrictConcurrency once LiveKit depends on a JWTKit
35-
// release where Sendable annotations are available (4.13.x patch or 5.x).
36-
// Tracking: https://github.com/livekit/client-sdk-swift/issues/846
3734
exclude: [
3835
"Internal/Protocol/schemas/agent.asyncapi.yaml"
3936
],

Package@swift-6.2.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ let package = Package(
3131
dependencies: [
3232
.product(name: "LiveKit", package: "client-sdk-swift")
3333
],
34-
// TODO: Re-enable StrictConcurrency once LiveKit depends on a JWTKit
35-
// release where Sendable annotations are available (4.13.x patch or 5.x).
36-
// Tracking: https://github.com/livekit/client-sdk-swift/issues/846
3734
exclude: [
3835
"Internal/Protocol/schemas/agent.asyncapi.yaml"
3936
],

Sources/ElevenLabs/Internal/Conversation/Conversation.swift

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -387,17 +387,10 @@ final class Conversation: ObservableObject {
387387

388388
setupAgentStateManager()
389389

390-
connectionManager.onEventReceived = { [weak self, weak connectionManager] event in
391-
Task { @MainActor [weak self, weak connectionManager] in
392-
guard let self,
393-
let connectionManager,
394-
activeConnectionManager === connectionManager,
395-
state.isConnecting || state.isConnected
396-
else {
397-
return
398-
}
399-
400-
await handleIncomingEvent(event)
390+
let connectionManagerID = ObjectIdentifier(connectionManager)
391+
connectionManager.onEventReceived = { [weak self] event in
392+
Task { @MainActor [weak self] in
393+
await self?.handleIncomingEvent(event, from: connectionManagerID)
401394
}
402395
}
403396
connectionManager.onDisconnected = { [weak self] in
@@ -406,6 +399,16 @@ final class Conversation: ObservableObject {
406399
}
407400
}
408401

402+
private func handleIncomingEvent(_ event: IncomingEvent, from connectionManagerID: ObjectIdentifier) async {
403+
guard let activeConnectionManager,
404+
ObjectIdentifier(activeConnectionManager) == connectionManagerID,
405+
state.isConnecting || state.isConnected
406+
else {
407+
return
408+
}
409+
await handleIncomingEvent(event)
410+
}
411+
409412
private func handleStartupFailure(
410413
_ error: ConversationError,
411414
disconnecting connectionManager: any ConnectionManaging

Sources/ElevenLabs/Internal/Conversation/ConversationAudioManager.swift

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import LiveKit
1010
final class ConversationAudioManager {
1111
private(set) var softwareMuteProcessor: SoftwareMuteProcessor?
1212

13-
private let audioManager = AudioManager.shared
1413
private var previousSpeechActivityHandler: AudioManager.OnSpeechActivity?
1514
private var audioSpeechHandlerInstalled = false
1615
private let logger: any Logging
@@ -22,7 +21,7 @@ final class ConversationAudioManager {
2221

2322
deinit {
2423
if audioSpeechHandlerInstalled {
25-
audioManager.onMutedSpeechActivity = previousSpeechActivityHandler
24+
AudioManager.shared.onMutedSpeechActivity = previousSpeechActivityHandler
2625
}
2726
}
2827

@@ -34,22 +33,22 @@ final class ConversationAudioManager {
3433
let muteMode = audioConfig?.microphoneMuteMode ?? .inputMixer
3534

3635
do {
37-
try audioManager.set(microphoneMuteMode: muteMode.toLiveKit())
36+
try AudioManager.shared.set(microphoneMuteMode: muteMode.toLiveKit())
3837
} catch {
3938
logger.warning("Failed to set microphone mute mode", context: ["error": "\(error)"])
4039
}
4140

4241
if let bypass = audioConfig?.voiceProcessingBypassed {
43-
audioManager.isVoiceProcessingBypassed = bypass
42+
AudioManager.shared.isVoiceProcessingBypassed = bypass
4443
}
4544

4645
if let agc = audioConfig?.voiceProcessingAGCEnabled {
47-
audioManager.isVoiceProcessingAGCEnabled = agc
46+
AudioManager.shared.isVoiceProcessingAGCEnabled = agc
4847
}
4948

5049
if let prepared = audioConfig?.recordingAlwaysPrepared {
5150
do {
52-
try await audioManager.setRecordingAlwaysPreparedMode(prepared)
51+
try await AudioManager.shared.setRecordingAlwaysPreparedMode(prepared)
5352
} catch {
5453
logger.warning("Failed to set recording always prepared mode", context: ["error": "\(error)"])
5554
}
@@ -70,7 +69,7 @@ final class ConversationAudioManager {
7069
private func setupInitialConfiguration() {
7170
// Set initial microphone mute mode
7271
do {
73-
try audioManager.set(microphoneMuteMode: LiveKit.MicrophoneMuteMode.inputMixer)
72+
try AudioManager.shared.set(microphoneMuteMode: LiveKit.MicrophoneMuteMode.inputMixer)
7473
} catch {
7574
logger.warning("Failed to set initial microphone mute mode", context: ["error": "\(error)"])
7675
}
@@ -79,7 +78,7 @@ final class ConversationAudioManager {
7978
Task { [weak self] in
8079
guard let self else { return }
8180
do {
82-
try await audioManager.setRecordingAlwaysPreparedMode(true)
81+
try await AudioManager.shared.setRecordingAlwaysPreparedMode(true)
8382
} catch {
8483
logger.warning("Failed to set recording always prepared mode", context: ["error": "\(error)"])
8584
}
@@ -89,14 +88,13 @@ final class ConversationAudioManager {
8988
private func configureSpeechHandler(muteMode: MicrophoneMuteMode, callbacks: ConversationCallbacks) {
9089
if muteMode == .voiceProcessing, let onSpeechDetectedWhileMuted = callbacks.onSpeechDetectedWhileMuted {
9190
if !audioSpeechHandlerInstalled {
92-
previousSpeechActivityHandler = audioManager.onMutedSpeechActivity
91+
previousSpeechActivityHandler = AudioManager.shared.onMutedSpeechActivity
9392
audioSpeechHandlerInstalled = true
9493
}
95-
audioManager.onMutedSpeechActivity = { _, event in
94+
AudioManager.shared.onMutedSpeechActivity = { _, event in
95+
guard event == .started else { return }
9696
Task { @MainActor in
97-
if event == .started {
98-
onSpeechDetectedWhileMuted()
99-
}
97+
onSpeechDetectedWhileMuted()
10098
}
10199
}
102100
} else if audioSpeechHandlerInstalled {
@@ -122,7 +120,7 @@ final class ConversationAudioManager {
122120

123121
private func cleanupSpeechHandler() {
124122
if audioSpeechHandlerInstalled {
125-
audioManager.onMutedSpeechActivity = previousSpeechActivityHandler
123+
AudioManager.shared.onMutedSpeechActivity = previousSpeechActivityHandler
126124
previousSpeechActivityHandler = nil
127125
audioSpeechHandlerInstalled = false
128126
}

Sources/ElevenLabs/Internal/Networking/ConnectionManaging.swift

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ enum ConnectionManagerError: Error {
55
case notConnected
66
}
77

8+
@MainActor
89
protocol ConnectionManaging: AnyObject {
910
var onEventReceived: (@Sendable (IncomingEvent) -> Void)? { get set }
1011
var onDisconnected: (() async -> Void)? { get set }
@@ -21,8 +22,10 @@ protocol ConnectionManaging: AnyObject {
2122
func send(data: Data) async throws
2223
}
2324

25+
@MainActor
2426
protocol WebSocketConnectionManaging: ConnectionManaging {}
2527

28+
@MainActor
2629
protocol WebRTCConnectionManaging: ConnectionManaging {
2730
var onRemoteSpeakingChanged: (@Sendable (Bool) -> Void)? { get set }
2831
/// Fired when an audio track is published/subscribed/unpublished/unsubscribed.
@@ -35,17 +38,19 @@ protocol WebRTCConnectionManaging: ConnectionManaging {
3538
}
3639

3740
extension ConnectionManaging {
38-
func handleIncomingData(
41+
/// Parses transport data off the main actor before delivering events on it.
42+
nonisolated static func handleIncomingData(
3943
_ data: Data,
4044
metadataWaiter: ConversationInitiationMetadataWaiter,
41-
logger: any Logging
42-
) {
45+
logger: any Logging,
46+
onEvent: @escaping @MainActor @Sendable (IncomingEvent) -> Void
47+
) async {
4348
do {
4449
if let event = try EventParser.parseIncomingEvent(from: data) {
4550
if case let .conversationMetadata(metadata) = event {
46-
Task { await metadataWaiter.observe(metadata) }
51+
await metadataWaiter.observe(metadata)
4752
}
48-
onEventReceived?(event)
53+
await onEvent(event)
4954
}
5055
} catch let EventParseError.unknownEventType(type) {
5156
// Unrecognized event types are expected (newer server) — not errors.

Sources/ElevenLabs/Internal/Networking/WebRTCConnectionManager.swift

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ enum WebRTCConnectionManagerError: Error {
2424
///
2525
/// LiveKit room/track types stay internal to this manager; consumers observe
2626
/// audio via ``ConversationAudioObserver`` rather than LiveKit track APIs.
27+
@MainActor
2728
final class WebRTCConnectionManager: WebRTCConnectionManaging {
2829
/// Fired when the remote agent leaves, the room disconnects, or all remote participants are gone.
2930
var onDisconnected: (() async -> Void)?
@@ -62,6 +63,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
6263
private var eventDelegate: LiveKitRoomEventDelegate?
6364
private var readinessDelegate: LiveKitReadinessDelegate?
6465
private var initiationMetadataWaiter: ConversationInitiationMetadataWaiter?
66+
private var dataTask: Task<Void, Never>?
6567

6668
private static let reliableDataPublishOptions = DataPublishOptions(reliable: true)
6769

@@ -185,7 +187,7 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
185187
}
186188
let first = await group.next()!
187189
if case .timedOut = first {
188-
await delegate.release()
190+
delegate.release()
189191
}
190192
group.cancelAll()
191193
return first
@@ -231,23 +233,42 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
231233
networkConfiguration: WebRTCConfiguration,
232234
metadataWaiter: ConversationInitiationMetadataWaiter
233235
) async throws {
234-
await readinessDelegate?.release()
236+
dataTask?.cancel()
237+
readinessDelegate?.release()
235238

236-
let readinessDelegate = await LiveKitReadinessDelegate(logger: logger)
239+
let readinessDelegate = LiveKitReadinessDelegate(logger: logger)
237240
self.readinessDelegate = readinessDelegate
238241

239242
let logger = logger
243+
let (dataStream, dataContinuation) = AsyncStream.makeStream(of: Data.self)
240244
let eventDelegate = LiveKitRoomEventDelegate(
241-
onData: { [weak self] data in
242-
self?.handleIncomingData(data, metadataWaiter: metadataWaiter, logger: logger)
245+
onData: { dataContinuation.yield($0) },
246+
onRemoteSpeaking: { [weak self] isSpeaking in
247+
Task { @MainActor [weak self] in
248+
self?.onRemoteSpeakingChanged?(isSpeaking)
249+
}
250+
},
251+
onRemoteDisconnect: { [weak self] in
252+
await self?.handleRemoteDisconnect()
243253
},
244-
onRemoteSpeaking: { [weak self] isSpeaking in self?.onRemoteSpeakingChanged?(isSpeaking) },
245-
onRemoteDisconnect: { [weak self] in await self?.onDisconnected?() },
246254
onTracksChanged: { [weak self] in
247255
Task { @MainActor in self?.onTracksChanged?() }
248256
}
249257
)
250258
self.eventDelegate = eventDelegate
259+
// One consumer preserves packet order while parsing off the main actor.
260+
dataTask = Task { [weak self] in
261+
for await data in dataStream {
262+
await Self.handleIncomingData(
263+
data,
264+
metadataWaiter: metadataWaiter,
265+
logger: logger,
266+
onEvent: { [weak self] event in
267+
self?.onEventReceived?(event)
268+
}
269+
)
270+
}
271+
}
251272

252273
let room = Room(roomOptions: RoomOptions(singlePeerConnection: true))
253274
self.room = room
@@ -299,21 +320,27 @@ final class WebRTCConnectionManager: WebRTCConnectionManaging {
299320

300321
await initiationMetadataWaiter?.cancel()
301322
initiationMetadataWaiter = nil
302-
await readinessDelegate?.release()
323+
dataTask?.cancel()
324+
dataTask = nil
325+
readinessDelegate?.release()
303326
readinessDelegate = nil
304327

305328
await room?.disconnect()
306329
room = nil
307330
eventDelegate = nil
308331
}
309332

333+
private func handleRemoteDisconnect() async {
334+
await onDisconnected?()
335+
}
336+
310337
// MARK: – Private helpers
311338

312339
/// Run one timed startup phase: record its duration into `metrics[keyPath:]`,
313340
/// let `CancellationError` propagate unwrapped, and wrap any other error as
314341
/// `ConversationError` (stamping `total`).
315342
@MainActor
316-
private func runPhase<T>(
343+
private func runPhase<T: Sendable>(
317344
timing keyPath: WritableKeyPath<ConversationStartupMetrics, TimeInterval?>,
318345
metrics: inout ConversationStartupMetrics,
319346
startTime: Date,

0 commit comments

Comments
 (0)