Skip to content

Commit 76b6deb

Browse files
authored
Legacy mic mute mode (#582)
Option to use legacy behavior: Restart engine with no mic when muted. Using WebRTC patch: webrtc-sdk/webrtc@40e7d20
1 parent ccf5256 commit 76b6deb

File tree

5 files changed

+202
-3
lines changed

5 files changed

+202
-3
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ let package = Package(
1818
],
1919
dependencies: [
2020
// LK-Prefixed Dynamic WebRTC XCFramework
21-
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "125.6422.17"),
21+
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "125.6422.18"),
2222
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.26.0"),
2323
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"),
2424
// Only used for DocC generation

[email protected]

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: "125.6422.17"),
23+
.package(url: "https://github.com/livekit/webrtc-xcframework.git", exact: "125.6422.18"),
2424
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.26.0"),
2525
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"),
2626
// Only used for DocC generation

Sources/LiveKit/Track/AudioManager.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,33 @@ public class AudioManager: Loggable {
258258
_state.mutate { $0.engineObservers = engineObservers }
259259
}
260260

261+
/// Set to `true` to enable legacy mic mute mode.
262+
///
263+
/// - Default: Uses `AVAudioEngine`'s `isVoiceProcessingInputMuted` internally.
264+
/// This is fast, and muted speaker detection works. However, iOS will play a sound effect.
265+
/// - Legacy: Restarts the internal `AVAudioEngine` without mic input when muted.
266+
/// This is slower, and muted speaker detection does not work. No sound effect is played.
267+
public var isLegacyMuteMode: Bool {
268+
get { RTC.audioDeviceModule.muteMode == .restartEngine }
269+
set { RTC.audioDeviceModule.muteMode = newValue ? .restartEngine : .voiceProcessing }
270+
}
271+
261272
// MARK: - For testing
262273

274+
var isEngineRunning: Bool {
275+
RTC.audioDeviceModule.isEngineRunning
276+
}
277+
278+
var isMicrophoneMuted: Bool {
279+
get { RTC.audioDeviceModule.isMicrophoneMuted }
280+
set { RTC.audioDeviceModule.isMicrophoneMuted = newValue }
281+
}
282+
283+
var engineState: RTCAudioEngineState {
284+
get { RTC.audioDeviceModule.engineState }
285+
set { RTC.audioDeviceModule.engineState = newValue }
286+
}
287+
263288
var isPlayoutInitialized: Bool {
264289
RTC.audioDeviceModule.isPlayoutInitialized
265290
}

Tests/LiveKitTests/MuteTests.swift

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
@testable import LiveKit
18+
import LiveKitWebRTC
19+
import XCTest
20+
21+
struct TestEngineTransition {
22+
let outputEnabled: ValueOrAbsent<Bool>
23+
let outputRunning: ValueOrAbsent<Bool>
24+
let inputEnabled: ValueOrAbsent<Bool>
25+
let inputRunning: ValueOrAbsent<Bool>
26+
let legacyMuteMode: ValueOrAbsent<Bool>
27+
let inputMuted: ValueOrAbsent<Bool>
28+
29+
init(outputEnabled: ValueOrAbsent<Bool> = .absent,
30+
outputRunning: ValueOrAbsent<Bool> = .absent,
31+
inputEnabled: ValueOrAbsent<Bool> = .absent,
32+
inputRunning: ValueOrAbsent<Bool> = .absent,
33+
legacyMuteMode: ValueOrAbsent<Bool> = .absent,
34+
inputMuted: ValueOrAbsent<Bool> = .absent)
35+
{
36+
self.outputEnabled = outputEnabled
37+
self.outputRunning = outputRunning
38+
self.inputEnabled = inputEnabled
39+
self.inputRunning = inputRunning
40+
self.legacyMuteMode = legacyMuteMode
41+
self.inputMuted = inputMuted
42+
}
43+
}
44+
45+
struct TestEngineAssert: Hashable {
46+
let engineRunning: Bool
47+
}
48+
49+
struct TestEngineStep {
50+
let transition: TestEngineTransition
51+
let assert: TestEngineAssert
52+
}
53+
54+
extension RTCAudioEngineState: CustomStringConvertible {
55+
public var description: String {
56+
"EngineState(" +
57+
"outputEnabled: \(outputEnabled), " +
58+
"outputRunning: \(outputRunning), " +
59+
"inputEnabled: \(inputEnabled), " +
60+
"inputRunning: \(inputRunning), " +
61+
"inputMuted: \(inputMuted), " +
62+
"muteMode: \(muteMode)" +
63+
")"
64+
}
65+
}
66+
67+
func applyEngineTransition(_ transition: TestEngineTransition) {
68+
let adm = AudioManager.shared
69+
var engineState = adm.engineState
70+
71+
if case let .value(value) = transition.outputEnabled {
72+
engineState.outputEnabled = value
73+
}
74+
75+
if case let .value(value) = transition.outputRunning {
76+
engineState.outputRunning = value
77+
}
78+
79+
if case let .value(value) = transition.inputEnabled {
80+
engineState.inputEnabled = value
81+
}
82+
83+
if case let .value(value) = transition.inputRunning {
84+
engineState.inputRunning = value
85+
}
86+
87+
if case let .value(value) = transition.inputMuted {
88+
engineState.inputMuted = value
89+
}
90+
91+
if case let .value(value) = transition.legacyMuteMode {
92+
engineState.muteMode = value ? .restartEngine : .voiceProcessing
93+
}
94+
95+
print("Testing engine state: \(engineState)")
96+
adm.engineState = engineState
97+
}
98+
99+
let standardEngineSteps: [TestEngineStep] = [
100+
// Enable output
101+
TestEngineStep(transition: .init(outputEnabled: .value(true)), assert: .init(engineRunning: false)),
102+
TestEngineStep(transition: .init(outputRunning: .value(true)), assert: .init(engineRunning: true)),
103+
// Enable input
104+
TestEngineStep(transition: .init(inputEnabled: .value(true)), assert: .init(engineRunning: true)),
105+
TestEngineStep(transition: .init(inputRunning: .value(true)), assert: .init(engineRunning: true)),
106+
// Disable input
107+
TestEngineStep(transition: .init(inputRunning: .value(false)), assert: .init(engineRunning: true)),
108+
TestEngineStep(transition: .init(inputEnabled: .value(false)), assert: .init(engineRunning: true)),
109+
// Disable output
110+
TestEngineStep(transition: .init(outputRunning: .value(false)), assert: .init(engineRunning: false)),
111+
TestEngineStep(transition: .init(outputEnabled: .value(false)), assert: .init(engineRunning: false)),
112+
]
113+
114+
let muteEngineSteps: [TestEngineStep] = [
115+
// Enable output
116+
TestEngineStep(transition: .init(outputEnabled: .value(true)), assert: .init(engineRunning: false)),
117+
TestEngineStep(transition: .init(outputRunning: .value(true)), assert: .init(engineRunning: true)),
118+
119+
// Enable input
120+
TestEngineStep(transition: .init(inputEnabled: .value(true)), assert: .init(engineRunning: true)),
121+
TestEngineStep(transition: .init(inputRunning: .value(true)), assert: .init(engineRunning: true)),
122+
123+
// Toggle mute
124+
TestEngineStep(transition: .init(inputMuted: .value(true)), assert: .init(engineRunning: true)),
125+
TestEngineStep(transition: .init(inputMuted: .value(false)), assert: .init(engineRunning: true)),
126+
127+
// Enable legacy mute mode
128+
TestEngineStep(transition: .init(legacyMuteMode: .value(true)), assert: .init(engineRunning: true)),
129+
130+
// Disable output
131+
TestEngineStep(transition: .init(outputRunning: .value(false)), assert: .init(engineRunning: true)),
132+
TestEngineStep(transition: .init(outputEnabled: .value(false)), assert: .init(engineRunning: true)),
133+
134+
// Engine should shut down at this point
135+
TestEngineStep(transition: .init(inputMuted: .value(true)), assert: .init(engineRunning: false)),
136+
137+
// Engine starts
138+
TestEngineStep(transition: .init(inputMuted: .value(false)), assert: .init(engineRunning: true)),
139+
140+
// Enable output
141+
TestEngineStep(transition: .init(outputEnabled: .value(true)), assert: .init(engineRunning: true)),
142+
TestEngineStep(transition: .init(outputRunning: .value(true)), assert: .init(engineRunning: true)),
143+
144+
// Mute
145+
TestEngineStep(transition: .init(inputMuted: .value(true)), assert: .init(engineRunning: true)),
146+
147+
// Disable input
148+
TestEngineStep(transition: .init(inputRunning: .value(false)), assert: .init(engineRunning: true)),
149+
TestEngineStep(transition: .init(inputEnabled: .value(false)), assert: .init(engineRunning: true)),
150+
151+
// Disable output
152+
TestEngineStep(transition: .init(outputRunning: .value(false)), assert: .init(engineRunning: false)),
153+
TestEngineStep(transition: .init(outputEnabled: .value(false)), assert: .init(engineRunning: false)),
154+
]
155+
156+
class MuteTests: XCTestCase {
157+
override func setUp() {
158+
super.setUp()
159+
continueAfterFailure = false
160+
}
161+
162+
func testTransitions() async throws {
163+
let adm = AudioManager.shared
164+
165+
for step in muteEngineSteps {
166+
applyEngineTransition(step.transition)
167+
// Check if engine running state is correct.
168+
XCTAssert(adm.isEngineRunning == step.assert.engineRunning)
169+
170+
let ns = UInt64(1 * 1_000_000_000)
171+
try await Task.sleep(nanoseconds: ns)
172+
}
173+
}
174+
}

Tests/LiveKitTests/Support/AudioRecorder.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class AudioRecorder {
2323
public let filePath: URL
2424
private var audioFile: AVAudioFile?
2525

26-
init(sampleRate: Double = 16000, channels: Int = 1) throws {
26+
init(sampleRate: Double = 48000, channels: Int = 1) throws {
2727
self.sampleRate = sampleRate
2828

2929
let settings: [String: Any] = [

0 commit comments

Comments
 (0)