Skip to content

Commit 8b44810

Browse files
authored
Add audio engine availability control (#786)
**Audio Engine Availability Control:** * Added `AudioEngineAvailability` struct and related conversion extensions in `AudioEngineAvailability.swift` to represent and manage input/output availability for the audio engine. * Introduced `setEngineAvailability(_:)` and `engineAvailability` API to `AudioManager`, providing high-priority control over the engine's running state. * Updated publishing logic in `LocalParticipant` to only wait for audio frames if input is available, respecting the new availability controls. **Testing and Documentation:** * Added `AudioEngineAvailabilityTests` to verify that the audio engine stops and resumes correctly when availability is toggled, ensuring robust behavior. * Added a helper sleep extension to `LKTestCase` to support async test scenarios. **Dependency Updates:** * Updated `LiveKitWebRTC` dependency to version `137.7151.08` in `LiveKitClient.podspec`, `Package.swift`, and `Package@swift-6.0.swift` to support the new audio engine availability features. [[1]](diffhunk://#diff-cd792874225249a1976737e2b629c17cc7c8bb8de175a12d6b93a4e49b33dedbL17-R17) [[2]](diffhunk://#diff-f913940c58e8744a2af1c68b909bb6383e49007e6c5a12fb03104a9006ae677eL22-R22) [[3]](diffhunk://#diff-8e7a2b05a09a07931d721896a66b6648bf9cc39fa80e2b4f65f3cdd6c8e553abL23-R23) Other minor changes include internal test updates to use `ObjCBool` for engine state properties.
1 parent 1f0a4a7 commit 8b44810

10 files changed

Lines changed: 132 additions & 10 deletions

File tree

.changes/audio-engine-availability

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="added" "audio engine availability control"

LiveKitClient.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Pod::Spec.new do |spec|
1414

1515
spec.source_files = "Sources/**/*"
1616

17-
spec.dependency("LiveKitWebRTC", "= 137.7151.04")
17+
spec.dependency("LiveKitWebRTC", "= 137.7151.08")
1818
spec.dependency("SwiftProtobuf")
1919
spec.dependency("Logging", "= 1.5.4")
2020
spec.dependency("DequeModule", "= 1.1.4")

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ let package = Package(
1919
],
2020
dependencies: [
2121
// LK-Prefixed Dynamic WebRTC XCFramework
22-
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.05"),
22+
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.08"),
2323
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"),
2424
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"),
2525
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"),

Package@swift-6.0.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ let package = Package(
2020
],
2121
dependencies: [
2222
// LK-Prefixed Dynamic WebRTC XCFramework
23-
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.05"),
23+
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "137.7151.08"),
2424
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"),
2525
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"),
2626
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"),

Sources/LiveKit/Audio/Manager/AudioManager.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,30 @@ public class AudioManager: Loggable {
309309
try checkAdmResult(code: result)
310310
}
311311

312+
/// Sets whether the internal `AVAudioEngine` is allowed to run.
313+
///
314+
/// This flag has the highest priority over any API that may start the engine
315+
/// (e.g., enabling the mic, ``startLocalRecording()``, or starting playback).
316+
///
317+
/// - Behavior:
318+
/// - When set to a disabled availability, the engine will stop if running,
319+
/// and it will not start, even if recording or playback is requested.
320+
/// - When set back to enabled, the engine will start as soon as possible
321+
/// if recording and/or playback had been previously requested while disabled
322+
/// (i.e., pending requests are honored once availability allows).
323+
///
324+
/// This is useful when you need to set up connections without touching the audio
325+
/// device yet (e.g., CallKit flows), or to guarantee the engine remains off
326+
/// regardless of subscription/publication requests.
327+
public func setEngineAvailability(_ availability: AudioEngineAvailability) throws {
328+
let result = RTC.audioDeviceModule.setEngineAvailability(availability.toRTCType())
329+
try checkAdmResult(code: result)
330+
}
331+
332+
public var engineAvailability: AudioEngineAvailability {
333+
RTC.audioDeviceModule.engineAvailability.toLKType()
334+
}
335+
312336
/// Set a chain of ``AudioEngineObserver``s.
313337
/// Defaults to having a single ``AudioSessionEngineObserver`` initially.
314338
///

Sources/LiveKit/Participant/LocalParticipant.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -693,8 +693,11 @@ extension LocalParticipant {
693693

694694
// At this point at least 1 audio frame should be generated to continue
695695
if let track = track as? LocalAudioTrack {
696-
log("[Publish] Waiting for audio frame...")
697-
try await track.startWaitingForFrames()
696+
// Only wait for frames if audio engine is allowed to start
697+
if AudioManager.shared.engineAvailability.isInputAvailable {
698+
log("[Publish] Waiting for audio frame...")
699+
try await track.startWaitingForFrames()
700+
}
698701
}
699702

700703
if track is LocalVideoTrack {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2025 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
internal import LiveKitWebRTC
18+
19+
public struct AudioEngineAvailability: Sendable {
20+
public static let `default` = AudioEngineAvailability(isInputAvailable: true, isOutputAvailable: true)
21+
public static let none = AudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false)
22+
23+
public let isInputAvailable: Bool
24+
public let isOutputAvailable: Bool
25+
26+
public init(isInputAvailable: Bool, isOutputAvailable: Bool) {
27+
self.isInputAvailable = isInputAvailable
28+
self.isOutputAvailable = isOutputAvailable
29+
}
30+
}
31+
32+
extension LKRTCAudioEngineAvailability {
33+
func toLKType() -> AudioEngineAvailability {
34+
AudioEngineAvailability(isInputAvailable: isInputAvailable.boolValue,
35+
isOutputAvailable: isOutputAvailable.boolValue)
36+
}
37+
}
38+
39+
extension AudioEngineAvailability {
40+
func toRTCType() -> LKRTCAudioEngineAvailability {
41+
LKRTCAudioEngineAvailability(isInputAvailable: ObjCBool(isInputAvailable),
42+
isOutputAvailable: ObjCBool(isOutputAvailable))
43+
}
44+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2025 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@preconcurrency import AVFoundation
18+
@testable import LiveKit
19+
import XCTest
20+
21+
class AudioEngineAvailabilityTests: LKTestCase {
22+
// Check if audio engine will stop when availability is set to .none,
23+
// then resume (restart) when availability is set back to .default.
24+
func testRecording() async throws {
25+
// First check
26+
XCTAssertFalse(AudioManager.shared.isEngineRunning)
27+
28+
// Start
29+
try AudioManager.shared.startLocalRecording()
30+
XCTAssertTrue(AudioManager.shared.isEngineRunning)
31+
32+
// Disable both input & output
33+
try AudioManager.shared.setEngineAvailability(.none)
34+
XCTAssertFalse(AudioManager.shared.isEngineRunning)
35+
36+
// Re-enable both input & output (default)
37+
try AudioManager.shared.setEngineAvailability(.default)
38+
XCTAssertTrue(AudioManager.shared.isEngineRunning)
39+
40+
// Stop
41+
try AudioManager.shared.stopLocalRecording()
42+
XCTAssertFalse(AudioManager.shared.isEngineRunning)
43+
}
44+
}

Tests/LiveKitTests/LKTestCase.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,9 @@ class LKTestCase: XCTestCase {
3232
super.setUp()
3333
}
3434
}
35+
36+
extension LKTestCase {
37+
func sleep(forSeconds seconds: UInt) async {
38+
try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
39+
}
40+
}

Tests/LiveKitTests/MuteTests.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,23 +69,23 @@ func applyEngineTransition(_ transition: TestEngineTransition) {
6969
var engineState = adm.engineState
7070

7171
if case let .value(value) = transition.outputEnabled {
72-
engineState.outputEnabled = value
72+
engineState.outputEnabled = ObjCBool(value)
7373
}
7474

7575
if case let .value(value) = transition.outputRunning {
76-
engineState.outputRunning = value
76+
engineState.outputRunning = ObjCBool(value)
7777
}
7878

7979
if case let .value(value) = transition.inputEnabled {
80-
engineState.inputEnabled = value
80+
engineState.inputEnabled = ObjCBool(value)
8181
}
8282

8383
if case let .value(value) = transition.inputRunning {
84-
engineState.inputRunning = value
84+
engineState.inputRunning = ObjCBool(value)
8585
}
8686

8787
if case let .value(value) = transition.inputMuted {
88-
engineState.inputMuted = value
88+
engineState.inputMuted = ObjCBool(value)
8989
}
9090

9191
if case let .value(value) = transition.legacyMuteMode {

0 commit comments

Comments
 (0)