import Foundation import LiveKitWebRTC /// What starts the microphone for the audio test outside a call: the /// test's microphone track, sent from one peer connection to another in /// this process, on the test's own factory. /// /// WebRTC opens the microphone when an audio stream starts sending — not /// when a track is made — and only then does its audio transport run the /// processing module, and with it the capture hook, on each 10 ms of /// capture. Asking the audio device module to record on its own /// (`initAndStartRecording`) does start the module, but the capture then /// never reaches the processing module: tried, and the hook saw no /// buffers at all. A negotiated send stream is what a call has, so it is /// what the test has. /// /// The two connections are negotiated (offer and answer, both in memory) /// and never connected: ICE gathers nothing (`iceTransportPolicy = .none`), /// so no socket is opened and nothing leaves the device. Negotiating is /// enough to start the stream sending into its transport, which is enough /// for WebRTC to open the microphone and process every buffer, as it does /// for a call waiting to connect. The receiving side hears nothing — no /// packet reaches it — and its track is silenced anyway. /// /// Every call to WebRTC here is made on the audio queue (PeerCalls): /// applying the answer starts the engine's playout, 188–229 ms measured, /// and in 0.3.8 the main thread waited for it. final class SelfTestLoopback: @unchecked Sendable { private let sender: RTCPeerConnection private let receiver: RTCPeerConnection /// Held until the connections are closed: a connection must never /// outlive its factory. private var factory: RTCPeerConnectionFactory? private var closed = false private init(sender: RTCPeerConnection, receiver: RTCPeerConnection, factory: RTCPeerConnectionFactory) { self.sender = sender self.receiver = receiver self.factory = factory } static func make(factory: RTCPeerConnectionFactory, track: RTCAudioTrack) async throws -> SelfTestLoopback { let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) let pair: SelfTestLoopback = try await AudioWork.run { let config = RTCConfiguration() config.sdpSemantics = .unifiedPlan config.iceTransportPolicy = .none guard let sender = factory.peerConnection(with: config, constraints: constraints, delegate: nil), let receiver = factory.peerConnection(with: config, constraints: constraints, delegate: nil) else { throw SelfTestLoopback.failed("the audio connection could not be made") } let pair = SelfTestLoopback(sender: sender, receiver: receiver, factory: factory) guard sender.add(track, streamIds: ["selftest"]) != nil else { pair.close() throw SelfTestLoopback.failed("the microphone could not be added") } return pair } do { try await pair.negotiate(constraints) } catch { pair.close() throw error } return pair } private func negotiate(_ constraints: RTCMediaConstraints) async throws { let offer = try await PeerCalls.describe(sender, answer: false, constraints) try await PeerCalls.set(sender, local: true, offer) try await PeerCalls.set(receiver, local: false, offer) await PeerCalls.read(receiver) { receiver in for transceiver in receiver.transceivers { (transceiver.receiver.track as? RTCAudioTrack)?.source.volume = 0 } } let answer = try await PeerCalls.describe(receiver, answer: true, constraints) try await PeerCalls.set(receiver, local: true, answer) try await PeerCalls.set(sender, local: false, answer) } /// Ends both, on the audio queue after anything already asked of them; /// WebRTC closes the microphone with the last sending stream. Safe to /// call twice. func close() { AudioWork.post { [self] in guard !closed else { return } closed = true sender.close() receiver.close() factory = nil } } private static func failed(_ why: String) -> NSError { NSError(domain: "Calls", code: 6, userInfo: [NSLocalizedDescriptionKey: why]) } }