oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import Combine
2import Foundation
3#if os(iOS)
4 import AVFoundation
5#endif
6// Go serializes this API with a mutex; Objective-C bindings carry no Sendable annotation.
7@preconcurrency import CallsBridge
8import LiveKitWebRTC
9
10// The Go callback only enqueues. The main actor owns the interface and media;
11// an epoch prevents callbacks from a closed room reaching a later call.
12private final class Events: NSObject, BridgeListenerProtocol {
13 weak var call: Call?
14 let epoch: UUID
15 init(call: Call, epoch: UUID) {
16 self.call = call
17 self.epoch = epoch
18 }
19 func onEvent(_ event: String?) {
20 guard let event, let data = event.data(using: .utf8) else { return }
21 guard let decoded = try? JSONDecoder().decode(WireEvent.self, from: data) else {
22 DispatchQueue.main.async { [weak self] in self?.call?.invalidEvents += 1 }
23 return
24 }
25 DispatchQueue.main.async { [weak self] in
26 guard let self, let call = self.call, call.epoch == self.epoch else { return }
27 call.receive(decoded)
28 }
29 }
30}
31
32@MainActor public final class Call: ObservableObject {
33 @Published public private(set) var state: RoomState? {
34 didSet { arrangement.roster(state) }
35 }
36 /// This viewer's layout, pin and order of people (0.4.0).
37 public let arrangement = CallArrangement()
38 @Published public private(set) var title = "Calls"
39 /// Whether `title` is one someone chose, shown over the call, rather
40 /// than one Calls wrote, which is not (CallTitle).
41 @Published public private(set) var named = false
42 @Published public private(set) var standing = ""
43 @Published public private(set) var status = "Ready to join"
44 @Published public private(set) var remoteTracks: [String: RTCVideoTrack] = [:]
45 /// §5.4: the peers showing a screen here, oldest first, and the screens
46 /// that have arrived. Two things and not one, because the `share`
47 /// message usually beats the media by a renegotiation, and the tile
48 /// carries its caption before there is anything to show in it.
49 @Published public private(set) var screens: [String] = []
50 @Published public private(set) var remoteScreens: [String: RTCVideoTrack] = [:]
51 /// Whether this app is sending a screen, and what the last attempt to
52 /// had to say — which is usually nothing.
53 @Published public private(set) var sharingScreen = false
54 @Published public private(set) var screenNote = ""
55 private var screenPending = false
56 /// How this app's own screen is going out, while it is sharing: the
57 /// slowest connection's, read once a second. See ScreenSendReport.
58 @Published public private(set) var screenReport: ScreenSendReport?
59 /// "Optimise for": what a shared screen gives up first. Remembered, and
60 /// applied at once to a share in progress (see ScreenSendPolicy).
61 @Published public var screenMode: ScreenShareMode = .remembered {
62 didSet {
63 guard screenMode != oldValue else { return }
64 ScreenShareMode.remembered = screenMode
65 built?.screenMode = screenMode
66 }
67 }
68 private var screenReports: [String: ScreenSendReport] = [:]
69 private var screenWatch: Task<Void, Never>?
70 /// The sharer's watchdog (ScreenWatchdog) and what it is doing, for the
71 /// panel's screen line.
72 private var screenWatchdog = ScreenWatchdog()
73 @Published public private(set) var screenWatchStatus = ""
74 /// The panel's second screen line: encoded and sent rates, keyframes,
75 /// and the watchdog (ScreenSendReport.detail).
76 @Published public private(set) var screenDetail = ""
77 /// The quiet notice while someone's view of this app's screen is being
78 /// put right: "Vicky's view of your screen stalled — recovering".
79 @Published public private(set) var screenRecovery = ""
80 /// The receiver's watchdog, and the list last reported as
81 /// `screenStalled` (spec §15.21).
82 private var screenReceiveWatch = ScreenReceiveWatch()
83 private var reportedStalled: [String] = []
84 /// Each arriving screen's statistics at the last look, for the stall
85 /// log's "what moved" and for "Copy call diagnostics".
86 private var screenReceived: [String: ScreenReceiveReport] = [:]
87 /// Where each arriving screen stood when it last moved, so a stall can
88 /// be described against it.
89 private var screenReceivedMoving: [String: ScreenReceiveReport] = [:]
90 private var screenReattaches = 0
91 /// "Reconnecting…", said only for a problem that lasts (spec §15.23):
92 /// the whole call's, and the people whose tiles say it on their own.
93 private var reconnectWatch = ReconnectWatch()
94 @Published public private(set) var reconnecting = false
95 @Published public private(set) var reconnectingPeers: Set<String> = []
96 /// "<name> is connecting and can't hear you yet" (spec §15.24): the
97 /// watch, and the people it is said for now — on their tiles, and as a
98 /// dot in the mini window when they are not the one it shows.
99 private var connectingWatch = ConnectingWatch()
100 @Published public private(set) var connectingPeers: Set<String> = []
101 /// This app's own half: whose audio path is established (HearingRule),
102 /// per connection generation; whose receiver reports have shown this
103 /// app's audio reaching them; and the list last reported as `hearing`.
104 private var heardFrom: [String: (epoch: Int64, heard: Bool)] = [:]
105 private var ackedBy: [String: Int64] = [:]
106 private var reportedHearing: [String] = []
107 /// Quality reports (spec §15.26): the notices tallied over the window,
108 /// the running totals the last report was taken from, the loop that
109 /// sends one every `qualityInterval`, and whether the server has said to
110 /// stop (its kill switch) or the final one has gone.
111 private var qualityTally = QualityEventTally()
112 private var qualityBuilder = QualityReportBuilder()
113 private var qualityWatch: Task<Void, Never>?
114 private var qualityStopped = false
115 private var qualityFinalSent = false
116 /// Thirty seconds, as the ruling has it; the gate shortens it
117 /// (CALLS_QUALITY_INTERVAL, read by the interop harness).
118 @_spi(Fixtures) public static var qualityInterval: Double = 30
119 /// "Report a problem" (spec §15.26): the sheet, while it is open.
120 @Published public var problemReport: ProblemReportModel?
121 /// Until when the last call can still be reported: ten minutes after it
122 /// ended, which is as long as the server keeps its report token. The
123 /// closed bridge client is kept that long — its transport and token
124 /// outlive the room — with the call's diagnostics as they were at the end.
125 @Published public private(set) var reportableUntil: Date?
126 private var reportClient: BridgeClient?
127 private var reportDiagnostics = ""
128 static let reportWindow: TimeInterval = 600
129 /// A fixture call answers a report itself, for the UI test of the sheet.
130 private var fixtureReports = false
131 /// The phase the notices are drawn for: the core's, except that its
132 /// `reconnecting` is only said once ReconnectWatch says so, and is said
133 /// for a second after it is over.
134 public var noticePhase: String? {
135 if reconnecting { return "reconnecting" }
136 if state?.phase == "reconnecting" { return "admitted" }
137 return state?.phase
138 }
139 private var screenWatchFrozenUntil = 0.0
140 @Published public private(set) var localTrack: RTCVideoTrack?
141 @Published public private(set) var muted = false
142 @Published public private(set) var cameraOff = false
143 @Published public private(set) var floor = false
144 @Published public private(set) var chat: [String] = []
145 @Published public private(set) var receivedChatCount = 0
146 @Published public private(set) var invitationURL: URL?
147 @Published public private(set) var invitationError = ""
148 @Published public private(set) var loadingInvitation = false
149 /// A brief message over the stage, such as "Invite link copied".
150 @Published public internal(set) var toast: String?
151 var toastSerial = 0
152 /// What the last device change had to say, and usually nothing.
153 @Published public private(set) var deviceNote = ""
154 /// The audio-health readout, once a call has been up long enough for
155 /// one. Nil means no sample yet, which is not the same as healthy.
156 @Published public private(set) var audioHealth: AudioHealth?
157 /// The camera in use. Published rather than read through the capture on
158 /// demand, because a picker bound to a plain computed property never
159 /// learns that a switch finished.
160 @Published public private(set) var currentCamera: String?
161 /// What the camera is doing (see CameraStatus), and a fallback worth
162 /// saying. Nil status for a capture that does not say, and outside a
163 /// call.
164 @Published public private(set) var cameraStatus: CameraStatus?
165 @Published public private(set) var cameraNote = ""
166 /// The working camera in use when it is not the remembered one, which
167 /// the panel offers to remember.
168 @Published public private(set) var cameraToKeep: CameraDevice?
169 /// A real camera, so the panel shows its row even with nothing in it.
170 @Published public private(set) var offersCameras = false
171 /// Audio routing belongs to this call.
172 public let audio = AudioRoutes()
173 /// "Reduce background noise": the neural suppressor in place of WebRTC's
174 /// own. On by default, and remembered.
175 @Published public var reduceNoise: Bool = UserDefaults.standard.object(forKey: Call.reduceNoiseKey) as? Bool ?? true {
176 didSet {
177 UserDefaults.standard.set(reduceNoise, forKey: Call.reduceNoiseKey)
178 guard let built else { return }
179 built.audio.reduceNoise = reduceNoise
180 audioProcessing = built.audio.report()
181 }
182 }
183 static let reduceNoiseKey = "reduceBackgroundNoise"
184 /// Which processing is running on the microphone, for the readout.
185 @Published public private(set) var audioProcessing: ProcessingReport?
186 /// The meters (spec §15.20): your microphone and everyone's sound, in
187 /// objects of their own so a meter moving does not redraw the call.
188 public let levels = AudioLevels()
189 private var levelWatch: Task<Void, Never>?
190 /// What was last reported as `noSoundFrom`, and the queue that reports
191 /// it: the bridge's send is a request, and not for the main thread.
192 private var reportedNoSound: [String] = []
193 private let noSoundQueue = DispatchQueue(label: "calls.no-sound-from")
194 /// Received bytes per person at the last statistics read, and whether
195 /// they had risen: "arriving" for the no-sound rule.
196 private var audioBytes: [String: UInt64] = [:]
197 private var audioArriving: [String: Bool] = [:]
198 /// Some connection is up, and so carrying the microphone to someone
199 /// (every connection is given the track when it is made). Read from the
200 /// peer sampling, five times a second; the microphone warning waits on
201 /// it (MicSample.sending).
202 private var sendingAudio = false
203 /// The loudest remote sound since the gate last asked (sampleStats).
204 private var remotePeakSinceSample: Float = 0
205 private var micPeakSinceSample: Float = 0
206 private var cameraWork: Task<Void, Never>?
207 private var cameraSwitches = 0
208 private let knocks = KnockWatch()
209 fileprivate var epoch = UUID()
210 private var bridge: BridgeClient?
211 private var events: Events?
212 /// The call's media, made by the first join (MediaEngine.make, off the
213 /// main thread) and kept for the next.
214 private var built: MediaEngine?
215 /// The media, made here and now only if something reaches for it
216 /// before any join has — which nothing in the app does.
217 private var media: MediaEngine {
218 if let built { return built }
219 AudioLog.info("call media made on the main thread, before a join")
220 let made = MediaEngine(call: self)
221 built = made
222 return made
223 }
224 /// The audio test's view of this call's microphone (AudioSelfTestModel):
225 /// the capture hook, nil outside a call, and whether WebRTC has opened it.
226 var captureDenoiser: CaptureDenoiser? { bridge == nil ? nil : built?.audio.denoiser }
227 var captureStarted: Bool { bridge != nil && built?.audio.capturing == true }
228
229 /// For "Copy audio diagnostics" in the panel's ⓘ and in Test audio
230 /// during a call: this call's microphone, devices, engine and hook.
231 func audioDiagnosticSections() -> [(String, [String])] {
232 var lines = [
233 "in a call: \(bridge != nil); muted \(muted); microphone track \(bridge != nil && built?.localAudio != nil)",
234 "people here: \(self.state?.peers.count ?? 0)",
235 "microphone meter: \(levels.mic.silent ? "silent" : "step \(levels.mic.step)"), speaking \(levels.mic.speaking), sending from \(levels.mic.source.isEmpty ? "-" : levels.mic.source)",
236 ]
237 if let health = audioHealth { lines.append("audio health: \(health.summary)") }
238 #if os(macOS)
239 let routeLines = audio.diagnostics()
240 #else
241 let routeLines = audio.inputs.map { "input: \($0.name) [\($0.id)]\($0.id == audio.currentInput ? " (current)" : "")" }
242 + ["output: \(audio.outputName)"]
243 #endif
244 var sections: [(String, [String])] = [("Call", lines), ("Devices", routeLines)]
245 if bridge != nil, let built { sections.append(("Audio engine and processing", built.audio.diagnostics())) }
246 return sections
247 }
248
249 public func audioDiagnosticsText() -> String {
250 diagnosticsText { self.name(of: $0) }
251 }
252
253 /// "Copy call diagnostics" as "Report a problem" sends it (spec §15.26):
254 /// everyone in it named "peer abc123", the first six characters of their
255 /// peer id, rather than by name. The server turns those into its tags.
256 /// Device names stay, as the spec says.
257 public func anonymousDiagnosticsText() -> String {
258 diagnosticsText(label: Self.anonymousLabel)
259 }
260
261 nonisolated static func anonymousLabel(_ peer: String) -> String { "peer " + peer.prefix(6) }
262
263 private func diagnosticsText(label: (String) -> String) -> String {
264 AudioDiagnosticsReport.make(
265 context: "a call's details",
266 sections: audioDiagnosticSections() + peopleDiagnosticSections(label: label)
267 + screenDiagnosticSections(label: label))
268 }
269
270 /// Everyone else in the call, as this app sees them: what they report
271 /// about their own media and hearing, and what this app is saying
272 /// about them.
273 private func peopleDiagnosticSections(label: (String) -> String) -> [(String, [String])] {
274 guard let state else { return [] }
275 var lines: [String] = []
276 for person in state.peers where person.peer != state.peer {
277 var facts = ["connection \(person.gen ?? 0)"]
278 if let media = person.media {
279 facts.append(media.muted ? "muted" : "unmuted")
280 facts.append(media.cameraOff ? "camera off" : "camera on")
281 if let hearing = media.hearing { facts.append(hearing.contains(state.peer) ? "hears you" : "does not hear you yet") }
282 if media.noSoundFrom?.contains(state.peer) == true { facts.append("hears no sound from you") }
283 if media.audioBlocked == true { facts.append("their browser blocked sound") }
284 } else {
285 facts.append("no media report")
286 }
287 if levels.noSoundFrom.contains(person.peer) { facts.append("no sound from them here") }
288 if reconnectingPeers.contains(person.peer) { facts.append("reconnecting") }
289 if connectingPeers.contains(person.peer) { facts.append("connecting, can't hear you yet") }
290 lines.append("\(label(person.peer)): " + facts.joined(separator: ", "))
291 }
292 return lines.isEmpty ? [] : [("People", lines)]
293 }
294
295 /// The screen's part of "Copy call diagnostics": how it is leaving, what
296 /// the watchdog has done, and whose screens have stalled here.
297 private func screenDiagnosticSections(label name: (String) -> String) -> [(String, [String])] {
298 var lines: [String] = []
299 if sharingScreen {
300 lines.append("Sharing (\(screenMode.rawValue))")
301 for (peer, report) in screenReports.sorted(by: { $0.key < $1.key }) {
302 lines.append("to \(name(peer)): \(report.summary)")
303 lines.append(" frames captured \(report.framesCaptured), encoded \(report.framesEncoded), sent \(report.framesSent), keyframes \(report.keyFramesEncoded), asked for \(report.keyframeRequests), limitation \(report.limitation.isEmpty ? "none" : report.limitation)")
304 }
305 if let capture = media.screenCaptureCounts {
306 lines.append("capture: \(capture.heartbeats ?? 0) reports, \(capture.delivered) frames delivered")
307 }
308 lines.append("watchdog: \(screenWatchdog.status)")
309 } else {
310 lines.append("Not sharing")
311 }
312 if sharingScreen {
313 for peer in (state?.peers ?? []).map(\.peer) where screenWatchdog.escalation(of: peer) != .none {
314 lines.append("recovering \(name(peer))'s view: \(screenWatchdog.escalation(of: peer))")
315 }
316 lines.append(
317 "recoveries: \(screenWatchdog.keyframes) keyframes, \(screenWatchdog.encoderRecoveries) fresh encoders, \(screenWatchdog.reAdds) re-adds, \(screenWatchdog.rebuilds) rebuilds, \(screenWatchdog.reshares) shares again, \(screenWatchdog.failures) given up")
318 }
319 // The receiver's half: every screen arriving here, as its statistics
320 // have it, whether or not it has stalled.
321 for (peer, report) in screenReceived.sorted(by: { $0.key < $1.key }) {
322 lines.append("from \(name(peer)): \(report.summary)")
323 }
324 if !reportedStalled.isEmpty {
325 lines.append("stalled here: " + reportedStalled.map { name($0) }.joined(separator: ", "))
326 }
327 if screenReattaches > 0 { lines.append("pictures attached again here: \(screenReattaches)") }
328 let log = AudioDiagnosticsStore.screen.lines(last: AudioDiagnosticsStore.lineLimit)
329 let connection = AudioDiagnosticsStore.connection.lines(last: 50)
330 return [
331 ("Screen", lines), ("Screen log (last \(AudioDiagnosticsStore.lineLimit) lines)", log.isEmpty ? ["(nothing logged)"] : log),
332 ("Connection log (last 50 lines)", connection.isEmpty ? ["(nothing logged)"] : connection),
333 ]
334 }
335 private var ice: ICEConfig?
336 private var pendingMedia: [Message] = []
337 fileprivate var invalidEvents: UInt64 = 0
338 let playAudio: Bool
339
340 #if os(iOS)
341 let customAudioDevice: RTCAudioDevice?
342 public init(audioDevice: RTCAudioDevice? = nil, playAudio: Bool = true) {
343 customAudioDevice = audioDevice
344 self.playAudio = playAudio
345 }
346 #else
347 let audioInput: AudioInputSource?
348 /// `audioInput` stands in for the microphone (the interop harness).
349 public init(playAudio: Bool = true, audioInput: AudioInputSource? = nil) {
350 self.playAudio = playAudio
351 self.audioInput = audioInput
352 }
353 #endif
354 public func join(origin: String, room: String, cookies: String, capture: VideoCapture? = nil)
355 async throws
356 {
357 let old = bridge
358 bridge = nil
359 close()
360 let joiningEpoch = epoch
361 if let old { await Task.detached { old.leave() }.value }
362 guard epoch == joiningEpoch else { throw CancellationError() }
363 let listener = Events(call: self, epoch: epoch)
364 var error: NSError?
365 guard let client = BridgeNewClient(origin, room, cookies, listener, &error) else {
366 throw error ?? NSError(domain: "Calls", code: 1)
367 }
368 events = listener
369 bridge = client
370 arrangement.begin(meeting: origin + "/" + room)
371 muted = false
372 cameraOff = false
373 chat = []
374 receivedChatCount = 0
375 title = "Calls"
376 standing = ""
377 // The factory and its audio device module, built on the audio queue
378 // the first time: the call's window is up while it is made.
379 if built == nil {
380 let made = await MediaEngine.make(call: self)
381 if built == nil { built = made }
382 }
383 guard epoch == joiningEpoch, bridge === client else { throw CancellationError() }
384 try await media.prepare(capture: capture)
385 guard epoch == joiningEpoch, bridge === client else { throw CancellationError() }
386 localTrack = media.localVideo
387 cameraChanged()
388 reportMedia()
389 media.audio.reduceNoise = reduceNoise
390 audioProcessing = media.audio.report()
391 watchLevels()
392 startQualityWatch()
393 KnockNotice.requestPermission()
394 // Asking Apple at join, once there is a session to register the
395 // answer against. Apple replies on its own schedule, at the app
396 // delegate, and may never reply at all.
397 RemoteKnocks.register()
398 try client.start()
399 }
400 public func close() {
401 epoch = UUID()
402 let old = bridge
403 bridge = nil
404 events = nil
405 built?.close()
406 // This call's microphone and speaker choices end with it (see AudioRoutes).
407 #if os(macOS)
408 audio.reset()
409 #endif
410 cameras = []
411 remoteTracks = [:]
412 screens = []
413 remoteScreens = [:]
414 sharingScreen = false
415 screenNote = ""
416 endScreenWatch()
417 localTrack = nil
418 ice = nil
419 state = nil
420 pendingMedia = []
421 invitationURL = nil
422 invitationError = ""
423 loadingInvitation = false
424 deviceNote = ""
425 audioHealth = nil
426 audioProcessing = nil
427 endLevelWatch()
428 qualityWatch?.cancel()
429 qualityWatch = nil
430 currentCamera = nil
431 cameraStatus = nil
432 cameraNote = ""
433 cameraToKeep = nil
434 knocks.reset()
435 cameraSwitches += 1
436 cameraWork?.cancel()
437 cameraWork = nil
438 if let old { DispatchQueue.global().async { old.close() } }
439 }
440 public func leave() async {
441 // The final quality report reads the connections' statistics, so it is
442 // built before they close, and sent beside the leave rather than after
443 // it: quitting gives the two together three seconds.
444 let final = await finalQualityReport()
445 let old = bridge
446 if let old { keepForReports(old) }
447 bridge = nil
448 close()
449 status = "You left the meeting"
450 if let old {
451 await Task.detached {
452 await withTaskGroup(of: Void.self) { group in
453 if let final { group.addTask { _ = old.sendQuality(final) } }
454 group.addTask { old.leave() }
455 }
456 }.value
457 }
458 }
459 public func mute() {
460 muted.toggle()
461 // On the audio queue: switching a track waits out an engine start,
462 // 1,136 ms measured on the main thread with a Bluetooth switch
463 // simulated — and the first thing someone does as they join is often
464 // to mute.
465 let track = media.localAudio
466 let on = !muted
467 AudioWork.post { track?.isEnabled = on }
468 reportMedia()
469 }
470 public func toggleCamera() {
471 cameraOff.toggle()
472 let track = media.localVideo
473 let on = !cameraOff
474 AudioWork.post { track?.isEnabled = on }
475 reportMedia()
476 }
477
478 // ---------------------------------------------------------------
479 // Media state (§15.19)
480 // ---------------------------------------------------------------
481
482 /// Tells the core what this app is sending. The core reports it once
483 /// admitted, again whenever a roster says the server has not got it —
484 /// a resume, a restart — and at once on a change, so this is called on
485 /// each toggle and once the tracks exist, and nowhere else. No track at
486 /// all is muted, or off, as far as anyone listening can tell.
487 private func reportMedia() {
488 try? bridge?.setMedia(muted || media.localAudio == nil, cameraOff: cameraOff || media.localVideo == nil)
489 }
490
491 /// A remote peer's reported state, or nil when it has not reported.
492 public func peerMedia(_ peer: String) -> PeerMedia? { Self.peerMedia(peer, in: state) }
493 /// Whether a remote peer has said it is muted. Unknown is not muted.
494 public func isMuted(_ peer: String) -> Bool { peerMedia(peer)?.muted ?? false }
495 /// Whether a remote peer has said its camera is off. Unknown is not off.
496 public func isCameraOff(_ peer: String) -> Bool { peerMedia(peer)?.cameraOff ?? false }
497 nonisolated static func peerMedia(_ peer: String, in state: RoomState?) -> PeerMedia? {
498 state?.peers.first { $0.peer == peer }?.media
499 }
500
501 // ---------------------------------------------------------------
502 // Devices
503 // ---------------------------------------------------------------
504
505 /// The cameras available, and the one in use. Read through the running
506 /// capture rather than from AVFoundation directly, so what the picker
507 /// offers is what the call could actually switch to.
508 @Published public private(set) var cameras: [CameraDevice] = []
509
510 public func refreshDevices() {
511 let available = built?.cameras ?? []
512 if cameras != available { cameras = available }
513 if let currentCamera, !available.contains(where: { $0.id == currentCamera }) {
514 self.currentCamera = nil
515 }
516 audio.refresh()
517 }
518
519 /// The capture said something changed on its own: a camera came or
520 /// went, a fallback, the first frame. Everything camera-shaped is read
521 /// back from it.
522 func cameraChanged() {
523 offersCameras = media.offersCameras
524 refreshDevices()
525 let now = media.currentCamera
526 if currentCamera != now { currentCamera = now }
527 let status = media.cameraStatus
528 if cameraStatus != status { cameraStatus = status }
529 let note = media.cameraNote
530 if cameraNote != note { cameraNote = note }
531 let keep = media.cameraToKeep
532 if cameraToKeep != keep { cameraToKeep = keep }
533 }
534
535 /// What your own tile says in place of your picture, or nil to show it.
536 /// Never an open-ended "Waiting for video": it says what it is waiting
537 /// for, or why there is nothing.
538 public var selfPlaceholder: String? {
539 if state?.phase == "ended" { return "Not in the call" }
540 if localTrack == nil { return "Starting camera…" }
541 return cameraStatus?.tileText
542 }
543
544 /// The panel's "Try again", for a call with no working camera.
545 public func retryCamera() async {
546 await media.retryCamera()
547 cameraChanged()
548 }
549
550 /// Switches camera mid-call. Serialised: two picks are two capture
551 /// restarts on one device, and run at once the survivor is whichever
552 /// session happened to open last, which is not the one the person
553 /// chose. The counter decides who wins; the queue decides that only
554 /// one runs.
555 public func useCamera(_ id: String) async {
556 cameraSwitches += 1
557 let mine = cameraSwitches
558 let previous = cameraWork
559 let work = Task { @MainActor [weak self] in
560 await previous?.value
561 guard let self, mine == self.cameraSwitches, !Task.isCancelled else { return }
562 do {
563 try await self.media.use(camera: id)
564 guard mine == self.cameraSwitches, !Task.isCancelled else { return }
565 self.deviceNote = ""
566 } catch is CancellationError {
567 } catch {
568 guard mine == self.cameraSwitches, !Task.isCancelled else { return }
569 self.deviceNote = error.localizedDescription
570 }
571 // Read back rather than assume: a refused switch left the capture
572 // wherever it actually is, and the picker has to follow the camera
573 // and not the intention.
574 guard mine == self.cameraSwitches, !Task.isCancelled else { return }
575 self.cameraChanged()
576 }
577 cameraWork = work
578 await work.value
579 }
580
581 /// Records something a device change had to say. The panel has no
582 /// place of its own to put an error, and a route that would not change
583 /// belongs beside the control that would not change it.
584 public func noteDevice(_ text: String) { deviceNote = text }
585
586 /// Polls the audio-health counters while a call is up.
587 ///
588 /// This exists because of a call on 2026-09-17 that came back as an
589 /// unintelligible growl, and because nothing in this app recorded
590 /// anything about it — no log, no counters, no artefact. A reproduction
591 /// would have produced nothing either. (Until the move to LiveKit's
592 /// WebRTC build it was also the only instrument on macOS, which had no
593 /// way to switch away from a suspect device.)
594 ///
595 /// Five seconds because this is a readout a person glances at when
596 /// something sounds wrong, not a time series.
597 public func watchAudio() async {
598 while !Task.isCancelled {
599 try? await Task.sleep(nanoseconds: 5_000_000_000)
600 guard !Task.isCancelled, bridge != nil else { return }
601 refreshDevices()
602 audioHealth = AudioHealth(await media.stats())
603 media.audio.refresh()
604 audioProcessing = media.audio.report()
605 }
606 }
607 // ---------------------------------------------------------------
608 // Levels (§15.20)
609 // ---------------------------------------------------------------
610
611 /// What a tile shows about its sound: yours, or a remote person's.
612 public var selfAudio: TileAudio { .you(levels.mic) }
613 public func tileAudio(_ peer: String) -> TileAudio {
614 .peer(levels.peer(peer), name: name(of: peer))
615 }
616
617 /// Reads the meters ten times a second while the call is up: your
618 /// microphone every tick, everyone else's every other (5 Hz), and the
619 /// received byte counts once a second. What is published only changes
620 /// when a meter step or a line does (see AudioLevels).
621 private func watchLevels() {
622 levelWatch?.cancel()
623 levels.reset()
624 resetConnecting()
625 reportedNoSound = []
626 audioBytes = [:]
627 audioArriving = [:]
628 levelWatch = Task { @MainActor [weak self] in
629 var tick = 0
630 while !Task.isCancelled {
631 try? await Task.sleep(nanoseconds: 100_000_000)
632 guard let self, !Task.isCancelled, self.bridge != nil else { return }
633 tick += 1
634 let now = ProcessInfo.processInfo.systemUptime
635 self.sampleMic(now: now)
636 if tick % 2 == 0 { await self.samplePeers(stats: tick % 10 == 0, now: now) }
637 if tick % 10 == 5 { await self.watchRemoteScreens(now: now) }
638 }
639 }
640 }
641
642 private func endLevelWatch() {
643 levelWatch?.cancel()
644 levelWatch = nil
645 screenReceiveWatch = ScreenReceiveWatch()
646 reportedStalled = []
647 screenReceived = [:]
648 screenReceivedMoving = [:]
649 levels.reset()
650 resetConnecting()
651 reportedNoSound = []
652 audioBytes = [:]
653 audioArriving = [:]
654 sendingAudio = false
655 }
656
657 private func resetConnecting() {
658 connectingWatch = ConnectingWatch()
659 if !connectingPeers.isEmpty { connectingPeers = [] }
660 heardFrom = [:]
661 ackedBy = [:]
662 // The core starts every report with `hearing: []` (spec §15.24).
663 reportedHearing = []
664 }
665
666 private func sampleMic(now: Double) {
667 let denoiser = media.audio.denoiser
668 let sent = denoiser.level.take()
669 let heard = denoiser.inputLevel.take()
670 micPeakSinceSample = max(micPeakSinceSample, sent.peak)
671 #if os(macOS)
672 let source = audio.inputSource
673 let channels = audio.inputChannels.count > 1
674 let capturing = media.audio.capturing
675 #else
676 let source = audio.inputs.first { $0.id == audio.currentInput }?.name ?? ""
677 let channels = false
678 let capturing = media.audio.hookFlowing
679 #endif
680 levels.updateMic(
681 MicSample(
682 muted: muted || media.localAudio == nil, capturing: capturing,
683 inputPeak: heard.peak, inputBuffers: heard.buffers, sentPeak: sent.peak, sending: sendingAudio),
684 now: now, source: source, channels: channels)
685 AudioDiagnosticsStore.shared.record(
686 .init(
687 at: now, input: heard.peak, inputBuffers: heard.buffers, output: sent.peak, outputBuffers: sent.buffers,
688 source: muted ? "call (muted)" : capturing ? "call" : "call (mic not open)"))
689 }
690
691 private func samplePeers(stats: Bool, now: Double) async {
692 // §15.24: the people whose audio path is not established yet are read
693 // closely, five times a second, until it is; the people in their first
694 // moments who do not say who they hear are asked about this app's audio.
695 let mine = state?.peer ?? ""
696 let joining = connectingWatch.joining
697 var detail = Set<String>()
698 var acks = Set<String>()
699 for person in state?.peers ?? [] where person.peer != mine {
700 let epoch = person.gen ?? 0
701 if heardFrom[person.peer].map({ $0.epoch != epoch || !$0.heard }) ?? true { detail.insert(person.peer) }
702 if joining.contains(person.peer), HearingRule.hearsYou(person.media, me: mine) == nil,
703 ackedBy[person.peer] != epoch
704 {
705 acks.insert(person.peer)
706 }
707 }
708 let readings = await media.peerAudio(stats: stats, detail: detail, acks: acks)
709 guard levelWatch != nil, let state else { return }
710 sendingAudio = readings.values.contains { $0.connected }
711 let me = state.peer
712 var present = Set<String>()
713 var people: [String: ReconnectWatch.Peer] = [:]
714 var arrivals: [String: ConnectingWatch.Peer] = [:]
715 for person in state.peers where person.peer != me {
716 guard let reading = readings[person.peer] else { continue }
717 present.insert(person.peer)
718 if stats, let bytes = reading.bytes {
719 audioArriving[person.peer] = bytes > (audioBytes[person.peer] ?? 0)
720 audioBytes[person.peer] = bytes
721 }
722 let epoch = person.gen ?? 0
723 var was = heardFrom[person.peer] ?? (epoch, false)
724 if was.epoch != epoch { was = (epoch, false) }
725 let heard = HearingRule.heard(
726 was: was.heard,
727 .init(
728 connected: reading.connected, bad: reading.gone, hasReceiver: reading.hasReceiver,
729 receiverLive: reading.receiverLive, packets: reading.packets ?? 0, decoded: reading.buffers,
730 senderMuted: person.media?.muted == true))
731 heardFrom[person.peer] = (epoch, heard)
732 if reading.acked == true { ackedBy[person.peer] = epoch }
733 arrivals[person.peer] = ConnectingWatch.Peer(
734 epoch: epoch, hearsMe: HearingRule.hearsYou(person.media, me: me), reported: person.media != nil,
735 connected: reading.connected, acked: ackedBy[person.peer] == epoch)
736 }
737 for id in Array(heardFrom.keys) where !present.contains(id) { heardFrom.removeValue(forKey: id) }
738 for id in Array(ackedBy.keys) where !present.contains(id) { ackedBy.removeValue(forKey: id) }
739 for id in connectingWatch.observe(at: now, people: arrivals) {
740 CallLog.info("\(id.prefix(6)) can hear us")
741 AudioAnnouncer.say(ConnectingWords.canHear(name(of: id)))
742 }
743 let nowJoining = connectingWatch.joining
744 if connectingPeers != connectingWatch.shown { connectingPeers = connectingWatch.shown }
745 for person in state.peers where person.peer != me {
746 guard let reading = readings[person.peer] else { continue }
747 // Someone in their first moments has not lost anything yet: §15.24
748 // speaks for them until it gives up.
749 people[person.peer] =
750 nowJoining.contains(person.peer)
751 ? ReconnectWatch.Peer()
752 : ReconnectWatch.Peer(
753 connectionBad: reading.connectionBad,
754 audioMissing: audioArriving[person.peer] == false && person.media?.muted != true)
755 let said = ConnectingWatch.tile(
756 name: person.name, media: person.media, me: me, muted: muted,
757 joining: nowJoining.contains(person.peer), shown: connectingPeers.contains(person.peer),
758 reconnecting: reconnectingPeers.contains(person.peer))
759 remotePeakSinceSample = max(remotePeakSinceSample, reading.peak)
760 levels.updatePeer(
761 person.peer, name: person.name,
762 sample: PeerAudioSample(
763 reported: person.media, connected: reading.connected,
764 arriving: audioArriving[person.peer] ?? false, level: reading.peak),
765 notices: said.notices, connecting: said.connecting,
766 now: now)
767 }
768 watchReconnect(now: now, people: people)
769 observeQuality(now: now, state: state)
770 levels.settle(present: present)
771 for id in Array(audioBytes.keys) where !present.contains(id) {
772 audioBytes.removeValue(forKey: id)
773 audioArriving.removeValue(forKey: id)
774 }
775 reportNoSound(levels.noSoundFrom)
776 reportHearing(heardFrom.filter { $0.value.heard }.keys.sorted(), present: present)
777 }
778
779 /// §15.24: whose audio this app is receiving and playing. Sent only on a
780 /// change, in order, off the main thread, as `noSoundFrom` is.
781 private func reportHearing(_ peers: [String], present: Set<String>) {
782 guard peers != reportedHearing, let client = bridge else { return }
783 let moved = HearingRule.moved(from: reportedHearing, to: peers, present: present)
784 reportedHearing = peers
785 guard moved else { return }
786 let body = json(peers) ?? "[]"
787 noSoundQueue.async { try? client.setHearing(body) }
788 }
789
790 /// One look by ReconnectWatch (spec §15.23), and the words it says.
791 private func watchReconnect(now: Double, people: [String: ReconnectWatch.Peer]) {
792 reconnectWatch.observe(at: now, streamDown: state?.phase == "reconnecting", people: people)
793 if reconnectingPeers != reconnectWatch.peers {
794 for id in reconnectWatch.peers.subtracting(reconnectingPeers) { CallLog.info("reconnecting to \(id.prefix(6)): shown") }
795 reconnectingPeers = reconnectWatch.peers
796 }
797 guard reconnecting != reconnectWatch.call else { return }
798 reconnecting = reconnectWatch.call
799 CallLog.info(reconnecting ? "reconnecting: shown (a problem that lasted)" : "reconnecting: cleared")
800 switch state?.phase {
801 case "reconnecting": status = reconnecting ? ReconnectWords.call : "In the meeting"
802 case "admitted": status = reconnecting ? ReconnectWords.call : "In the meeting"
803 default: break
804 }
805 }
806
807 /// §15.20's receiving half: who is reaching this app as silence. The
808 /// core keeps only roster peers and reports on a change; this sends only
809 /// on a change too, in order, off the main thread.
810 private func reportNoSound(_ peers: [String]) {
811 guard peers != reportedNoSound, let client = bridge else { return }
812 reportedNoSound = peers
813 let body = json(peers) ?? "[]"
814 noSoundQueue.async { try? client.setNoSoundFrom(body) }
815 }
816
817 public func retryJoin() { send(type: "join", to: "", gen: 0, payload: "") }
818 public func loadInvitation() async {
819 guard let client = bridge, !loadingInvitation else { return }
820 let currentEpoch = epoch
821 invitationURL = nil
822 invitationError = ""
823 loadingInvitation = true
824 defer { if epoch == currentEpoch { loadingInvitation = false } }
825 do {
826 let link = try await Task.detached {
827 var error: NSError?
828 let value = client.shareLink(&error)
829 if let error { throw error }
830 return value
831 }.value
832 guard epoch == currentEpoch else { return }
833 invitationURL = URL(string: link)
834 } catch {
835 guard epoch == currentEpoch else { return }
836 invitationError = "Could not get the invitation. Try again."
837 }
838 }
839 public func admit(_ peer: String) {
840 send(type: "admit", to: "", gen: 0, payload: json(["peer": peer]) ?? "")
841 }
842 @discardableResult public func sendChat(_ text: String) -> Bool {
843 guard !text.isEmpty else { return false }
844 guard let payload = json(["text": text]), payload.utf8.count <= 2059 else {
845 status = "Keep the message within 2 KB."
846 return false
847 }
848 guard let bridge else { return false }
849 do {
850 try bridge.send("chat", to: "", generation: 0, payload: payload)
851 return true
852 } catch {
853 status = "Could not send the message. Try again."
854 return false
855 }
856 }
857 func send(type: String, to: String, gen: Int64, payload: String) {
858 do { try bridge?.send(type, to: to, generation: gen, payload: payload) } catch {
859 status = "Could not send to the meeting. Try again."
860 }
861 }
862 fileprivate func receive(_ event: WireEvent) {
863 switch event.type {
864 case "sent", "send-failed":
865 guard let message = event.signal, message.type == "chat",
866 let text = message.payload?.text
867 else { return }
868 appendChat("\(event.type == "sent" ? "You" : "Not sent"): \(text)")
869 case "info":
870 if let info = event.info {
871 title = info.title
872 named = info.named ?? false
873 standing = info.standing
874 floor = info.floor
875 }
876 case "state":
877 guard let state = event.state else { return }
878 self.state = state
879 // Only a member is sent a waiting list at all (spec §4.3), so this
880 // needs no check of its own for who is allowed to be told.
881 KnockNotice.announce(
882 knocks.arrivals(waiting: state.waiting, visible: KnockNotice.appIsFrontmost))
883 switch state.phase {
884 case "admitted": status = reconnecting ? ReconnectWords.call : "In the meeting"
885 case "waiting": status = "Waiting to be let in"
886 case "full": status = "The meeting is full. Try joining again shortly."
887 // Said only once it has lasted (ReconnectWatch, spec §15.23): a
888 // stream renewal is a reconnect of half a second nobody should see.
889 case "reconnecting": if reconnecting { status = ReconnectWords.call }
890 case "ended":
891 status =
892 [
893 "left": "You left the meeting", "ended": "This meeting has ended",
894 "replaced": "This meeting is open elsewhere",
895 "removed": "You were removed from this meeting",
896 "standing": "You no longer have access", "expired": "Your invitation has expired",
897 "signin": "Sign in again to join", "unavailable": "This meeting is unavailable",
898 ][state.reason ?? ""] ?? "The call has ended"
899 endedForReports()
900 media.close()
901 remoteTracks = [:]
902 screens = []
903 remoteScreens = [:]
904 sharingScreen = false
905 endScreenWatch()
906 endLevelWatch()
907 localTrack = nil
908 cameraChanged()
909 default: status = "Connecting…"
910 }
911 if state.peer.isEmpty {
912 media.closePeers()
913 remoteTracks = [:]
914 screens = []
915 remoteScreens = [:]
916 ice = nil
917 }
918 if let ice { media.reconcile(state: state, ice: ice) }
919 case "ice":
920 guard let config = event.ice else { return }
921 let renewing = ice != nil
922 ice = config
923 floor = config.iceServers.isEmpty
924 if let state { media.reconcile(state: state, ice: config) }
925 if renewing { media.updateICE(config) }
926 let pending = pendingMedia
927 pendingMedia = []
928 for message in pending { media.receive(message) }
929 case "ice-unavailable": status = "Could not refresh the call connection. Retrying…"
930 case "message":
931 guard let message = event.message else { return }
932 if message.type == "chat", let text = message.payload?.text {
933 appendChat("\(message.name ?? "Participant"): \(text)")
934 receivedChatCount += 1
935 }
936 if message.type == "share" {
937 media.receiveShare(message)
938 return
939 }
940 if ice == nil, ["offer", "answer", "candidate"].contains(message.type) {
941 if pendingMedia.count < 256 { pendingMedia.append(message) }
942 } else {
943 media.receive(message)
944 }
945 default: break
946 }
947 }
948 private func appendChat(_ line: String) {
949 chat.append(line)
950 if chat.count > 200 { chat.removeFirst(chat.count - 200) }
951 }
952 func setRemote(_ track: RTCVideoTrack, peer: String) { remoteTracks[peer] = track }
953 func removeRemote(_ peer: String) {
954 remoteTracks.removeValue(forKey: peer)
955 dropScreen(peer)
956 }
957
958 // ---------------------------------------------------------------
959 // Screens (§5.4)
960 // ---------------------------------------------------------------
961
962 /// The name the roster gives a peer, which is the only place a name
963 /// comes from: the hub put it there, and nothing a peer sends can.
964 public func name(of peer: String) -> String {
965 state?.peers.first { $0.peer == peer }?.name ?? "Participant"
966 }
967
968 /// Shares a screen (§5.4): the capture asks the person what to share,
969 /// every connection gets it on a transceiver of its own, and then the
970 /// room is told — the label, not the media, so peers can caption the
971 /// tile before the renegotiation lands.
972 ///
973 /// A refusal at the picker says one sentence, the browser's, and does
974 /// not diagnose which kind of refusal it was.
975 public func shareScreen(_ capture: ScreenCapture) async {
976 // The picker stays open for as long as a person takes to answer it,
977 // and the button is live the whole time. Without this a second press
978 // starts a second capture that nothing would ever stop.
979 guard bridge != nil, !sharingScreen, !screenPending else { return }
980 screenPending = true
981 defer { screenPending = false }
982 let joined = epoch
983 screenNote = ""
984 media.screenMode = screenMode
985 do {
986 try await media.startScreen(capture) { [weak self] in
987 // The capture ended on its own: the system's stop control, or
988 // the shared window closing. The same ending as the button.
989 guard let self, self.epoch == joined else { return }
990 self.stopSharingScreen()
991 }
992 } catch is CancellationError {
993 return
994 } catch {
995 guard epoch == joined else { return }
996 screenNote = "Screen sharing was not allowed."
997 return
998 }
999 guard epoch == joined, bridge != nil else {
1000 media.stopScreen()
1001 return
1002 }
1003 sharingScreen = true
1004 screenWatchdog.reset()
1005 ScreenLog.info("sharing started (\(screenMode.rawValue))")
1006 send(type: "share", to: "", gen: 0, payload: json(["on": true]) ?? "")
1007 watchScreen()
1008 }
1009
1010 /// Reads the screen's outbound statistics once a second while sharing,
1011 /// for the device panel's line and the gate.
1012 private func watchScreen() {
1013 screenWatch?.cancel()
1014 screenWatch = Task { @MainActor [weak self] in
1015 while !Task.isCancelled {
1016 try? await Task.sleep(nanoseconds: 1_000_000_000)
1017 guard let self, !Task.isCancelled, self.sharingScreen else { return }
1018 let fresh = await self.media.screenReports()
1019 guard !Task.isCancelled, self.sharingScreen else { return }
1020 var measured: [String: ScreenSendReport] = [:]
1021 for (peer, report) in fresh {
1022 measured[peer] = report.measured(since: self.screenReports[peer])
1023 }
1024 let previous = self.screenReports
1025 self.screenReports = measured
1026 let worst = ScreenSendReport.worst(Array(measured.values))
1027 self.screenReport = worst
1028 // Motion mode scales from the source's own size, which only the
1029 // statistics know (a window can be any shape, and re-picked).
1030 if let worst { self.media.screenSource(width: worst.sourceWidth, height: worst.sourceHeight) }
1031 await self.runScreenWatchdog(measured, previous: previous)
1032 }
1033 }
1034 }
1035
1036 /// Off only for the gate's measurement of what happens without it.
1037 @_spi(Fixtures) public static var screenWatchdogEnabled = true
1038
1039 /// One look by the sharer's watchdog, and whatever it asks for.
1040 private func runScreenWatchdog(
1041 _ reports: [String: ScreenSendReport], previous previousReports: [String: ScreenSendReport]
1042 ) async {
1043 guard sharingScreen, let capture = media.screenCaptureCounts else { return }
1044 let me = state?.peer ?? ""
1045 let stalledBy = Set(
1046 (state?.peers ?? []).filter { $0.peer != me && ($0.media?.screenStalled ?? []).contains(me) }.map(\.peer))
1047 let sending = reports.mapValues {
1048 ScreenSendCounts(
1049 encoded: $0.framesEncoded, sent: $0.framesSent, keyframes: $0.keyFramesEncoded,
1050 bandwidthLimited: $0.limitation == "bandwidth")
1051 }
1052 let failuresBefore = screenWatchdog.failures
1053 var actions = screenWatchdog.observe(
1054 at: ProcessInfo.processInfo.systemUptime, capture: capture, sending: sending, stalledBy: stalledBy)
1055 if !Self.screenWatchdogEnabled { actions = [] }
1056 countScreenSteps(actions, failed: screenWatchdog.failures - failuresBefore, stalledBy: stalledBy)
1057 for action in actions {
1058 switch action {
1059 case .restartCapture:
1060 ScreenLog.info("restarting the capture")
1061 let ok = await media.restartScreenCapture()
1062 if !ok { ScreenLog.error("the capture could not be restarted") }
1063 case .keyframe(let peer):
1064 ScreenLog.info("keyframe for \(ScreenWatchdog.short(peer))")
1065 media.forceScreenKeyframe(peer)
1066 case .recreateEncoder(let peer):
1067 ScreenLog.info("fresh encoder for \(ScreenWatchdog.short(peer))")
1068 media.recreateScreenEncoder(peer)
1069 case .reAdd(let peer):
1070 ScreenLog.info("screen re-added for \(ScreenWatchdog.short(peer))")
1071 media.reAddScreen(peer)
1072 sayShareAgain()
1073 case .rebuild(let peer):
1074 ScreenLog.info("screen rebuilt for \(ScreenWatchdog.short(peer)): new transceiver, same size and mode")
1075 media.reAddScreen(peer)
1076 sayShareAgain()
1077 case .reshare:
1078 ScreenLog.info("sharing again for everyone, from the same capture")
1079 await media.reshareScreen()
1080 sayShareAgain()
1081 }
1082 }
1083 screenWatchStatus = screenWatchdog.status
1084 if let (peer, worst) = reports.min(by: { $0.value.framesPerSecond < $1.value.framesPerSecond }) {
1085 screenDetail = worst.detail(since: previousReports[peer], watchdog: screenWatchdog.status)
1086 }
1087 var recovering: [String] = []
1088 var restarting: [String] = []
1089 var failed: [String] = []
1090 for peer in stalledBy.sorted(by: { name(of: $0) < name(of: $1) }) {
1091 switch screenWatchdog.escalation(of: peer) {
1092 case .rebuilt, .reshared: restarting.append(name(of: peer))
1093 case .failed: failed.append(name(of: peer))
1094 case .none, .keyframes: recovering.append(name(of: peer))
1095 }
1096 }
1097 screenRecovery = ScreenWords.notice(recovering: recovering, restarting: restarting, failed: failed)
1098 }
1099
1100 /// A new transceiver's track arrives unlabelled; say it again.
1101 private func sayShareAgain() {
1102 Task { @MainActor [weak self] in
1103 try? await Task.sleep(nanoseconds: 2_000_000_000)
1104 guard let self, self.sharingScreen else { return }
1105 self.send(type: "share", to: "", gen: 0, payload: json(["on": true]) ?? "")
1106 }
1107 }
1108 private func endScreenWatch() {
1109 screenWatch?.cancel()
1110 screenWatch = nil
1111 screenReports = [:]
1112 screenReport = nil
1113 screenWatchdog.reset()
1114 screenWatchStatus = ""
1115 screenRecovery = ""
1116 screenDetail = ""
1117 }
1118
1119 /// The receiver's watchdog, once a second from the level watch: whose
1120 /// screens have stopped moving here, reported as `screenStalled` when
1121 /// that changes (spec §15.21), each stall logged with what the
1122 /// statistics say is still arriving, and the picture attached again
1123 /// four seconds in (spec §15.22).
1124 private func watchRemoteScreens(now: Double) async {
1125 var stalled: [String] = []
1126 if !screens.isEmpty {
1127 let reports = await media.remoteScreenReports()
1128 var frames = reports.mapValues(\.framesDecoded)
1129 // The gate's frozen picture: the counts held where they were.
1130 if now < screenWatchFrozenUntil { frames = frames.mapValues { _ in 0 } }
1131 let sharing = Set(screens)
1132 let before = screenReceived
1133 screenReceived = reports.filter { sharing.contains($0.key) }
1134 stalled = screenReceiveWatch.observe(at: now, decoded: frames.filter { sharing.contains($0.key) })
1135 for (peer, report) in screenReceived where !stalled.contains(peer) {
1136 if before[peer].map({ $0.framesDecoded != report.framesDecoded || $0.mid != report.mid }) ?? true {
1137 screenReceivedMoving[peer] = report
1138 }
1139 }
1140 for peer in stalled where !reportedStalled.contains(peer) {
1141 let report = screenReceived[peer] ?? ScreenReceiveReport()
1142 let moving = screenReceivedMoving[peer]
1143 ScreenLog.info(
1144 "screen from \(ScreenWatchdog.short(peer)) stalled here: reporting it; since it last moved \(report.moved(since: moving)) — \(report.diagnosis(since: moving))")
1145 ScreenLog.info("screen from \(ScreenWatchdog.short(peer)): \(report.summary)")
1146 }
1147 for peer in screenReceiveWatch.reattach {
1148 let report = screenReceived[peer] ?? ScreenReceiveReport()
1149 ScreenLog.info(
1150 "screen from \(ScreenWatchdog.short(peer)) still stalled: attaching its picture again; \(report.moved(since: screenReceivedMoving[peer]))")
1151 reattachScreen(peer)
1152 }
1153 } else {
1154 screenReceiveWatch = ScreenReceiveWatch()
1155 screenReceived = [:]
1156 screenReceivedMoving = [:]
1157 }
1158 guard stalled != reportedStalled, let client = bridge else { return }
1159 for peer in reportedStalled where !stalled.contains(peer) {
1160 let report = screenReceived[peer]
1161 ScreenLog.info(
1162 "screen from \(ScreenWatchdog.short(peer)) moving here again\(report.map { ": " + $0.summary } ?? "")")
1163 }
1164 reportedStalled = stalled
1165 let body = json(stalled) ?? "[]"
1166 noSoundQueue.async { try? client.setScreenStalled(body) }
1167 }
1168
1169 /// A stalled screen's picture dropped and attached again: a new renderer
1170 /// on the same track, for a picture stopped on this side.
1171 private func reattachScreen(_ peer: String) {
1172 guard let track = remoteScreens[peer] else { return }
1173 screenReattaches += 1
1174 qualityTally.count(.reattach, for: peer)
1175 remoteScreens.removeValue(forKey: peer)
1176 Task { @MainActor [weak self] in
1177 try? await Task.sleep(nanoseconds: 150_000_000)
1178 guard let self, self.screens.contains(peer), self.remoteScreens[peer] == nil else { return }
1179 self.remoteScreens[peer] = track
1180 }
1181 }
1182
1183 /// Stops sharing, from every way out: the button, the system's own stop
1184 /// control, and the capture ending under us.
1185 public func stopSharingScreen() {
1186 guard sharingScreen else { return }
1187 sharingScreen = false
1188 ScreenLog.info("sharing stopped")
1189 endScreenWatch()
1190 // What was never built has no screen to stop.
1191 built?.stopScreen()
1192 send(type: "share", to: "", gen: 0, payload: json(["on": false]) ?? "")
1193 }
1194
1195 /// A peer has said it is sharing, or its screen has arrived. Either one
1196 /// is enough to draw the tile.
1197 func announceScreen(_ peer: String) {
1198 if !screens.contains(peer) { screens.append(peer) }
1199 }
1200 func setScreen(_ track: RTCVideoTrack, peer: String) {
1201 announceScreen(peer)
1202 remoteScreens[peer] = track
1203 }
1204 /// Both of §5.4's endings meet here — the `share {on:false}` message
1205 /// and the receiver going away with its stopped transceiver — and
1206 /// whichever comes first wins. The second finds nothing to do.
1207 func dropScreen(_ peer: String) {
1208 screens.removeAll { $0 == peer }
1209 remoteScreens.removeValue(forKey: peer)
1210 }
1211 func mediaFailed() { status = "A participant’s media could not connect." }
1212 /// Caps the call's uplink estimate at `bps` (nil lifts it): the gate's
1213 /// stand-in for a thin connection, which it has no other way to get.
1214 public func limitUplink(_ bps: Int?) { media.limitUplink(bps) }
1215 /// For the gate: this app's receiver watch sees every screen frozen for
1216 /// `seconds`, so its stall report and the sharer's answer can be driven
1217 /// end to end.
1218 @_spi(Fixtures) public func freezeScreenWatchForTest(seconds: Double) {
1219 screenWatchFrozenUntil = ProcessInfo.processInfo.systemUptime + seconds
1220 }
1221 /// For the gate's encoder stall (see MediaEngine).
1222 @_spi(Fixtures) public func stallScreenEncoderForTest() { media.stallScreenEncoderForTest() }
1223 /// For the gate: the watchdog's share again (spec §15.22), on demand.
1224 @_spi(Fixtures) public func reshareScreenForTest() async {
1225 guard sharingScreen else { return }
1226 ScreenLog.info("sharing again for everyone, from the same capture (asked for by the gate)")
1227 await media.reshareScreen()
1228 sayShareAgain()
1229 }
1230 public func sampleStats() async -> [String: UInt64] {
1231 var values = await media.stats()
1232 values["hasICE"] = ice == nil ? 0 : 1
1233 values["remoteTracks"] = UInt64(remoteTracks.count)
1234 values["remoteScreens"] = UInt64(remoteScreens.count)
1235 values["screens"] = UInt64(screens.count)
1236 values["invalidEvents"] = invalidEvents
1237 // §15.24: people still in their first moments, people whose tiles say
1238 // they cannot hear you yet, and how many this app reports it hears.
1239 values["connectingJoining"] = UInt64(connectingWatch.joining.count)
1240 values["connectingShown"] = UInt64(connectingPeers.count)
1241 values["hearing"] = UInt64(reportedHearing.count)
1242 // This app's own screen, as it leaves: the slowest connection's.
1243 if let screen = screenReport {
1244 values["screenSendWidth"] = UInt64(screen.width)
1245 values["screenSendHeight"] = UInt64(screen.height)
1246 values["screenSendFpsTenths"] = UInt64((screen.framesPerSecond * 10).rounded())
1247 values["screenCapturedFpsTenths"] = UInt64((screen.capturedPerSecond * 10).rounded())
1248 values["screenFramesSent"] = screen.framesSent
1249 values["screenFramesEncoded"] = screen.framesEncoded
1250 values["screenFramesCaptured"] = screen.framesCaptured
1251 values["screenLimitation"] = screen.limitationCode
1252 values["screenLimitedBandwidthMs"] = UInt64(((screen.limitationSeconds["bandwidth"] ?? 0) * 1000).rounded())
1253 values["screenLimitedCpuMs"] = UInt64(((screen.limitationSeconds["cpu"] ?? 0) * 1000).rounded())
1254 values["screenTargetBitrate"] = screen.targetBitrate
1255 values["screenSentBitrate"] = screen.sentBitrate
1256 values["screenEncodeMsTenths"] = UInt64((screen.encodeMsPerFrame * 10).rounded())
1257 values["screenHardwareEncoder"] = screen.hardwareEncoder ? 1 : 0
1258 values["screenCodecH264"] = screen.codec.lowercased().hasSuffix("h264") ? 1 : 0
1259 values["screenPath"] = screen.pathCode
1260 values["screenRttMs"] = UInt64((screen.roundTripTime * 1000).rounded())
1261 values["screenPacketsLost"] = UInt64(max(0, screen.packetsLost))
1262 }
1263 // The screen watchdogs (ScreenWatchdog): what the sharer's has done,
1264 // how many stall reports it has heard, and whose screens this app has
1265 // reported stalled.
1266 values["screenWatchdogCaptureRestarts"] = UInt64(screenWatchdog.captureRestarts)
1267 values["screenWatchdogKeyframes"] = UInt64(screenWatchdog.keyframes)
1268 values["screenWatchdogEncoderRecoveries"] = UInt64(screenWatchdog.encoderRecoveries)
1269 values["screenWatchdogReAdds"] = UInt64(screenWatchdog.reAdds)
1270 values["screenStallReports"] = UInt64(screenWatchdog.stallReports)
1271 values["screenStalledHere"] = UInt64(reportedStalled.count)
1272 values["screenWatchdogRebuilds"] = UInt64(screenWatchdog.rebuilds)
1273 values["screenWatchdogReshares"] = UInt64(screenWatchdog.reshares)
1274 values["screenWatchdogFailures"] = UInt64(screenWatchdog.failures)
1275 values["screenReattaches"] = UInt64(screenReattaches)
1276 values["iceRestarts"] = media.iceRestarts
1277 values["screenKeyframes"] = screenReport?.keyFramesEncoded ?? 0
1278 if let capture = media.screenCaptureCounts {
1279 values["screenCaptureHeartbeats"] = capture.heartbeats ?? 0
1280 values["screenCaptureDelivered"] = capture.delivered
1281 }
1282 values["rosterPeers"] = UInt64(state?.peers.count ?? 0)
1283 values["noiseFramesProcessed"] = media.audio.denoiser.framesProcessed
1284 values["noiseSuppressor"] = media.audio.denoiser.engineName == "RNNoise" ? 1 : 2
1285 if case .running(_, let cpu) = media.audio.denoiser.state {
1286 values["noiseCpuPermille"] = UInt64((cpu * 1000).rounded())
1287 }
1288 // What WebRTC reports running, not what was asked of it.
1289 let running = media.audio.report()
1290 values["webrtcEchoCancellation"] = running.echoCancellation == "off" ? 0 : 1
1291 values["webrtcNoiseSuppression"] = running.noiseSuppression == "off" ? 0 : 1
1292 values["webrtcGainControl"] = running.gainControl.hasPrefix("Calls") || running.gainControl == "off" ? 0 : 1
1293 // Whether the readout would say the microphone has started.
1294 values["audioReadoutStarted"] = running.started ? 1 : 0
1295 // §15.20's meters, as the tiles see them: the loudest remote sound and
1296 // the loudest sent sound since the last sample, in thousandths of full
1297 // scale; who is in "No sound from"; and whether your own tile says
1298 // the microphone is picking nothing up.
1299 values["remoteAudioPeakPermille"] = UInt64((remotePeakSinceSample * 1000).rounded())
1300 values["micPeakPermille"] = UInt64((micPeakSinceSample * 1000).rounded())
1301 remotePeakSinceSample = 0
1302 micPeakSinceSample = 0
1303 values["noSoundFrom"] = UInt64(levels.noSoundFrom.count)
1304 values["micSilent"] = levels.mic.silent ? 1 : 0
1305 // The speaking glow behind your own name (0.3.7): lit while what is
1306 // sent is speech that has kept up.
1307 values["micGlow"] = levels.mic.glow.glow.lit ? 1 : 0
1308 return values
1309 }
1310}
1311
1312// ---------------------------------------------------------------
1313// Quality and problem reports (§15.26)
1314// ---------------------------------------------------------------
1315
1316extension Call {
1317 /// Sends a quality report every `qualityInterval` while admitted: the
1318 /// window starts at admission, and each report covers the time since
1319 /// the last. Read and built on the main actor, as the statistics reads
1320 /// for the meters are; sent off it, because the bridge's send is a
1321 /// request.
1322 fileprivate func startQualityWatch() {
1323 qualityWatch?.cancel()
1324 qualityTally = QualityEventTally()
1325 qualityBuilder = QualityReportBuilder()
1326 qualityStopped = false
1327 qualityFinalSent = false
1328 qualityWatch = Task { @MainActor [weak self] in
1329 while !Task.isCancelled {
1330 try? await Task.sleep(nanoseconds: 1_000_000_000)
1331 guard let self, !Task.isCancelled, let client = self.bridge else { return }
1332 let now = ProcessInfo.processInfo.systemUptime
1333 guard self.qualityBuilder.started else {
1334 if self.admittedForReports {
1335 self.qualityBuilder.start(at: now)
1336 self.qualityTally = QualityEventTally()
1337 }
1338 continue
1339 }
1340 guard self.qualityBuilder.elapsed(at: now) >= Self.qualityInterval else { continue }
1341 guard self.admittedForReports, !self.qualityStopped, QualityReporting.enabled() else {
1342 // Off, stopped, or between participations: the window moves on
1343 // with nothing sent, so a report never covers time it did not see.
1344 self.qualityBuilder.start(at: now)
1345 self.qualityTally = QualityEventTally()
1346 continue
1347 }
1348 let samples = await self.built?.qualitySamples() ?? [:]
1349 guard !Task.isCancelled, self.bridge === client else { return }
1350 guard let body = self.qualityReport(at: ProcessInfo.processInfo.systemUptime, samples: samples, final: false)
1351 else { continue }
1352 Task.detached { [weak self] in
1353 let answer = client.sendQuality(body)
1354 if answer == "stop" {
1355 await MainActor.run { [weak self] in
1356 self?.qualityStopped = true
1357 CallLog.info("quality reports: the server asked for no more this call")
1358 }
1359 }
1360 }
1361 }
1362 }
1363 }
1364
1365 private var admittedForReports: Bool {
1366 guard let phase = state?.phase else { return false }
1367 return phase == "admitted" || phase == "reconnecting"
1368 }
1369
1370 private func qualityReport(at now: Double, samples: [String: QualityPeerSample], final: Bool) -> String? {
1371 qualityBuilder.build(
1372 at: now, samples: samples, events: &qualityTally, client: QualityClientInfo.current(),
1373 devices: qualityDevices(), final: final
1374 ).encoded()
1375 }
1376
1377 /// The report sent at leave, built while the connections are still up;
1378 /// nil when there is none to send.
1379 fileprivate func finalQualityReport() async -> String? {
1380 guard let client = bridge, qualityBuilder.started, !qualityStopped, !qualityFinalSent,
1381 QualityReporting.enabled()
1382 else { return nil }
1383 let samples = await built?.qualitySamples() ?? [:]
1384 guard bridge === client, !qualityFinalSent else { return nil }
1385 qualityFinalSent = true
1386 return qualityReport(at: ProcessInfo.processInfo.systemUptime, samples: samples, final: true)
1387 }
1388
1389 /// The call ended under this app (ended, removed, open elsewhere): the
1390 /// final report goes now, with the notices but without statistics — the
1391 /// connections are closing as it is built — and the call stays
1392 /// reportable from here.
1393 fileprivate func endedForReports() {
1394 guard let client = bridge else { return }
1395 keepForReports(client)
1396 guard qualityBuilder.started, !qualityStopped, !qualityFinalSent, QualityReporting.enabled() else { return }
1397 qualityFinalSent = true
1398 guard let body = qualityReport(at: ProcessInfo.processInfo.systemUptime, samples: [:], final: true) else { return }
1399 Task.detached { _ = client.sendQuality(body) }
1400 }
1401
1402 /// The kinds of microphone and speaker in use. Never their names.
1403 private func qualityDevices() -> QualityDevices? {
1404 #if os(macOS)
1405 let kinds = audio.deviceKinds()
1406 return QualityDevices(input: kinds.input, output: kinds.output)
1407 #else
1408 let route = AVAudioSession.sharedInstance().currentRoute
1409 return QualityDevices(
1410 input: DeviceKind.ios(port: route.inputs.first?.portType.rawValue),
1411 output: DeviceKind.ios(port: route.outputs.first?.portType.rawValue))
1412 #endif
1413 }
1414
1415 /// One look by the level watch, tallied for the window's events.
1416 fileprivate func observeQuality(now: Double, state: RoomState) {
1417 guard qualityBuilder.started else { return }
1418 let me = state.peer
1419 var fromUs = Set<String>()
1420 var blocked = Set<String>()
1421 for person in state.peers where person.peer != me {
1422 if person.media?.noSoundFrom?.contains(me) == true { fromUs.insert(person.peer) }
1423 if person.media?.audioBlocked == true { blocked.insert(person.peer) }
1424 }
1425 qualityTally.observe(
1426 at: now, call: reconnecting, reconnecting: reconnectingPeers, cantHear: connectingPeers,
1427 noSound: Set(levels.noSoundFrom), noSoundFromUs: fromUs, audioBlocked: blocked,
1428 screenStalled: Set(reportedStalled))
1429 }
1430
1431 /// The sharer's watchdog's steps, toward the peers they were for.
1432 fileprivate func countScreenSteps(_ actions: [ScreenRecovery], failed: Int, stalledBy: Set<String>) {
1433 let me = state?.peer ?? ""
1434 for action in actions {
1435 switch action {
1436 case .keyframe(let peer): qualityTally.count(.keyframe, for: peer)
1437 case .reAdd(let peer), .rebuild(let peer): qualityTally.count(.rebuild, for: peer)
1438 case .reshare:
1439 for person in state?.peers ?? [] where person.peer != me { qualityTally.count(.reshare, for: person.peer) }
1440 case .restartCapture, .recreateEncoder: break
1441 }
1442 }
1443 guard failed > 0 else { return }
1444 for peer in stalledBy where screenWatchdog.escalation(of: peer) == .failed {
1445 qualityTally.count(.failure, for: peer)
1446 }
1447 }
1448
1449 /// Keeps a call's closed client, and its diagnostics as they are now,
1450 /// for "Report a problem" during the ten minutes after it.
1451 fileprivate func keepForReports(_ client: BridgeClient) {
1452 guard reportClient !== client else { return }
1453 reportClient = client
1454 reportDiagnostics = anonymousDiagnosticsText()
1455 let until = Date().addingTimeInterval(Self.reportWindow)
1456 reportableUntil = until
1457 Task { @MainActor [weak self] in
1458 try? await Task.sleep(nanoseconds: UInt64(Self.reportWindow * 1_000_000_000))
1459 guard let self, self.reportableUntil == until else { return }
1460 self.reportableUntil = nil
1461 self.reportClient = nil
1462 self.reportDiagnostics = ""
1463 }
1464 }
1465
1466 /// Whether "Report a problem" has anything to report: a call in
1467 /// progress, or one that ended less than ten minutes ago.
1468 public var canReportProblem: Bool {
1469 bridge != nil || fixtureReports || (reportableUntil.map { $0 > Date() } ?? false)
1470 }
1471
1472 /// Opens the sheet. In a call it reports this call, its diagnostics read
1473 /// now; after one, the call that ended, as it was when it ended.
1474 public func reportProblem() {
1475 let info = QualityClientInfo.current()
1476 let deliver: @Sendable (String) async -> ProblemOutcome
1477 if let client = bridge ?? (canReportProblem ? reportClient : nil) {
1478 let diagnostics = bridge != nil ? anonymousDiagnosticsText() : reportDiagnostics
1479 deliver = { note in
1480 let body = ProblemReportBody(note: note, diagnostics: diagnostics, client: info).encoded()
1481 return await Task.detached { ProblemOutcome(bridgeAnswer: client.sendProblem(body)) }.value
1482 }
1483 } else if fixtureReports {
1484 // The UI test's call has no server: it is answered here, after a
1485 // moment, with a reference of the server's shape.
1486 deliver = { _ in
1487 try? await Task.sleep(nanoseconds: 300_000_000)
1488 return .sent(ref: "K7Q2M9PD")
1489 }
1490 } else {
1491 return
1492 }
1493 problemReport = ProblemReportModel(deliver: deliver)
1494 }
1495}
1496
1497// ---------------------------------------------------------------
1498// Fixtures
1499// ---------------------------------------------------------------
1500
1501extension Call {
1502 /// Puts the call into a described state without a server, for the
1503 /// offline snapshots of the call views and the iPhone's UI test of its
1504 /// controls. `state` is a roster in the wire's own JSON. Never used by
1505 /// a call that has joined anything.
1506 @_spi(Fixtures) public func showFixture(
1507 title: String, standing: String = "member", status: String = "In the meeting",
1508 state json: String, screens: [String] = [], sharingScreen: Bool = false,
1509 muted: Bool = false, floor: Bool = false, named: Bool = false
1510 ) {
1511 self.title = title
1512 self.named = named
1513 self.standing = standing
1514 self.state = try? JSONDecoder().decode(RoomState.self, from: Data(json.utf8))
1515 self.status = status
1516 // A fixture's reconnecting is one that has lasted (spec §15.23).
1517 self.reconnecting = self.state?.phase == "reconnecting"
1518 self.screens = screens
1519 self.sharingScreen = sharingScreen
1520 self.muted = muted
1521 self.floor = floor
1522 self.fixtureReports = true
1523 }
1524
1525 /// Your own microphone, camera and screen, set over a fixture for the
1526 /// snapshots of the controls in each state.
1527 @_spi(Fixtures) public func showFixtureSelf(muted: Bool, cameraOff: Bool, sharingScreen: Bool) {
1528 self.muted = muted
1529 self.cameraOff = cameraOff
1530 self.sharingScreen = sharingScreen
1531 }
1532
1533 /// Spec §15.24 for the snapshots: these people have just joined and the
1534 /// grace has passed, so each tile says what it would — "connecting", or
1535 /// a report of theirs that replaces it.
1536 @_spi(Fixtures) public func showFixtureConnecting(_ peers: [String]) {
1537 let me = state?.peer ?? ""
1538 var shown: Set<String> = []
1539 for person in state?.peers ?? [] where peers.contains(person.peer) {
1540 let said = ConnectingWatch.tile(
1541 name: person.name, media: person.media, me: me, muted: muted, joining: true, shown: true,
1542 reconnecting: false)
1543 if said.connecting != nil { shown.insert(person.peer) }
1544 levels.peer(person.peer).show(
1545 state: person.media?.muted == true ? .muted : .meter(0), speaking: false, notices: said.notices,
1546 connecting: said.connecting)
1547 }
1548 connectingPeers = shown
1549 }
1550
1551 /// Everyone's glow, and yours, held at one level, for the snapshots.
1552 @_spi(Fixtures) public func showFixtureSpeaking(level: Float) {
1553 levels.mic.glow.showFixture(level: level)
1554 // Nobody muted glows: muted, the speaking gate is shut.
1555 if muted { levels.mic.glow.reset() }
1556 for peer in state?.peers ?? [] where peer.peer != state?.peer && !isMuted(peer.peer) {
1557 levels.peer(peer.peer).glow.showFixture(level: level)
1558 }
1559 levels.othersGlow.showFixture(level: level)
1560 levels.anyoneSpeaking.set(levels.othersGlow.glow.lit)
1561 }
1562}
1563