Skip to content

Echo agent tests #692

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
183 changes: 183 additions & 0 deletions Tests/LiveKitTests/Audio/EchoTesting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
* Copyright 2025 LiveKit
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@preconcurrency import AVFoundation
@testable import LiveKit
import LiveKitWebRTC
import XCTest

class EchoTests: LKTestCase {
static let startTest = "start_test"
static let stopTest = "stop_test"
static let rmsKey = "lk.rms" // RMS level of the far-end signal
static let peakKey = "lk.peak" // Peak level of the far-end signal
static let vadKey = "lk.vad" // Number of speech events in the far-end signal

struct TestCase {
let title: String
let enableAppleVp: Bool
let captureOptions: AudioCaptureOptions?
}

struct TestResult: CustomStringConvertible {
let testCase: TestCase
let vadCount: Int
let maxPeak: Float

var description: String {
"\(testCase.title) VAD: \(vadCount), Peak: \(maxPeak)"
}
}

// Actor to safely manage state across concurrent contexts
actor TestStateActor {
private(set) var vadResult: Int = 0
private(set) var peakResult: Float = -120.0

func updateVad(_ newValue: Int) {
if newValue > vadResult {
print("Updating VAD \(vadResult) -> \(newValue)")
vadResult = newValue
}
}

func updatePeak(_ newValue: Float) {
if newValue > peakResult {
print("Updating PEAK \(peakResult) -> \(newValue)")
peakResult = newValue
}
}

func getResults(for testCase: TestCase) -> TestResult {
TestResult(testCase: testCase, vadCount: vadResult, maxPeak: peakResult)
}
}

func runEchoAgent(testCase: TestCase) async throws -> TestResult {
// No-VP
try! AudioManager.shared.setVoiceProcessingEnabled(testCase.enableAppleVp)
// Bypass VP
AudioManager.shared.isVoiceProcessingBypassed = !testCase.enableAppleVp

return try await withRooms([RoomTestingOptions(canPublish: true, canPublishData: true, canSubscribe: true)]) { rooms in
// Alias to Room
let room1 = rooms[0]

// Sleep for 3 seconds for agent to join...
try? await Task.sleep(nanoseconds: 3 * 1_000_000_000)

// Find agent
let echoAgent: RemoteParticipant? = room1.remoteParticipants.values.first { $0.kind == .agent }
XCTAssert(echoAgent != nil, "Agent participant not found") // Echo agent must be running
guard let agentIdentity = echoAgent?.identity else {
XCTFail("Echo agent's identity is nil")
fatalError()
}

let state = TestStateActor()

try await room1.registerTextStreamHandler(for: Self.vadKey) { [state] reader, _ in
let resultString = try await reader.readAll()
guard let result = Int(resultString) else { return }
await state.updateVad(result)
}

try await room1.registerTextStreamHandler(for: Self.peakKey) { [state] reader, _ in
let resultString = try await reader.readAll()
guard let result = Float(resultString) else { return }
await state.updatePeak(result)
}

// Enable mic
try await room1.localParticipant.setMicrophone(enabled: true, captureOptions: testCase.captureOptions)

// Sleep for 1 seconds...
try? await Task.sleep(nanoseconds: 1 * 1_000_000_000)

// Check Apple VP
XCTAssert(testCase.enableAppleVp == AudioManager.shared.isVoiceProcessingEnabled)
XCTAssert(testCase.enableAppleVp != AudioManager.shared.isVoiceProcessingBypassed)

// Check APM is enabled
let apmConfig = RTC.audioProcessingModule.config
print("APM Config: \(apmConfig.toDebugString()))")
XCTAssert((testCase.captureOptions?.echoCancellation ?? false) == apmConfig.isEchoCancellationEnabled)
XCTAssert((testCase.captureOptions?.autoGainControl ?? false) == apmConfig.isAutoGainControl1Enabled)
XCTAssert((testCase.captureOptions?.noiseSuppression ?? false) == apmConfig.isNoiseSuppressionEnabled)
XCTAssert((testCase.captureOptions?.highpassFilter ?? false) == apmConfig.isHighpassFilterEnabled)

// Start test
_ = try await room1.localParticipant.performRpc(destinationIdentity: agentIdentity, method: Self.startTest, payload: "")

// Sleep for 30 seconds...
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000)

// Stop test
_ = try await room1.localParticipant.performRpc(destinationIdentity: agentIdentity, method: Self.stopTest, payload: "")

// Get final results from the actor
return await state.getResults(for: testCase)
}
}

func testEchoAgent() async throws {
let defaultTestCase = TestCase(title: "Default", enableAppleVp: true, captureOptions: nil)
let allCaptureOptions = AudioCaptureOptions(echoCancellation: true,
autoGainControl: true,
noiseSuppression: true,
highpassFilter: true)
let testCases = [
TestCase(title: "None", enableAppleVp: false, captureOptions: nil),
TestCase(title: "RTC VP Only", enableAppleVp: false, captureOptions: allCaptureOptions),
TestCase(title: "Both", enableAppleVp: true, captureOptions: allCaptureOptions),
]

// Run default test first
print("Running Default test case...")
let defaultResult = try await runEchoAgent(testCase: defaultTestCase)
print("Result: \(defaultTestCase.title) \(defaultResult)")

// Store results for each test case
var testResults: [TestResult] = []

// Run other test cases
for testCase in testCases {
print("Running \(testCase.title) test case...")
let result = try await runEchoAgent(testCase: testCase)
testResults.append(result)
print("Result: \(testCase.title) \(result)")
}

// Print summary after all tests have completed
print("\n======= Test Results Summary =======")
print("Default: \(defaultResult)")

for result in testResults {
let vadDiff = result.vadCount - defaultResult.vadCount
let peakDiff = result.maxPeak - defaultResult.maxPeak

print("\(result)")
print(" Compared to Default:")
print(" - VAD difference: \(vadDiff > 0 ? "+" : "")\(vadDiff) events")
print(" - Peak difference: \(peakDiff > 0 ? "+" : "")\(String(format: "%.2f", peakDiff)) dB")

// Optional basic analysis
let vadPercentChange = defaultResult.vadCount > 0 ? Float(vadDiff) / Float(defaultResult.vadCount) * 100 : 0
print(" - VAD % change: \(String(format: "%.1f", vadPercentChange))%")
}
print("===================================")
}
}
10 changes: 6 additions & 4 deletions Tests/LiveKitTests/Support/Room.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@
}

// Set up variable number of Rooms
func withRooms(_ options: [RoomTestingOptions] = [],
sharedKey: String = UUID().uuidString,
_ block: @escaping ([Room]) async throws -> Void) async throws
func withRooms<T>(_ options: [RoomTestingOptions] = [],
sharedKey: String = UUID().uuidString,
_ block: @escaping ([Room]) async throws -> T) async throws -> T
{
let e2eeOptions = E2EEOptions(keyProvider: BaseKeyProvider(isSharedKey: true, sharedKey: sharedKey))

Expand Down Expand Up @@ -180,7 +180,7 @@

let allRooms = rooms.map(\.room)
// Execute block
try await block(allRooms)
let result = try await block(allRooms)

// Disconnect all Rooms concurrently
try await withThrowingTaskGroup(of: Void.self) { group in
Expand All @@ -191,7 +191,9 @@
}
try await group.waitForAll()
}

return result
}

Check failure on line 196 in Tests/LiveKitTests/Support/Room.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-14, 15.4, iOS Simulator,OS=17.5,name=iPhone 15 Pro)

testParticipantCleanUp, failed: caught error: "Error Domain=io.livekit.swift-sdk Code=202 "Network error(Validation response: "success")" UserInfo={NSLocalizedDescription=Network error(Validation response: "success")}"
}

extension Array where Element: Comparable {
Expand Down
Loading