| 1 | import Foundation |
| 2 | |
| 3 | // Spec §15.20: what the meters show and when the "no sound" lines appear, |
| 4 | // as rules the tests can hold with an injected clock. Nothing here touches |
| 5 | // WebRTC or the main actor; `Call` feeds these from the capture hook |
| 6 | // (CaptureLevel) and the per-peer receive tap (RemoteAudioTap). |
| 7 | // |
| 8 | // Levels are 0...1 of full scale throughout, as CaptureLevel reports |
| 9 | // them, and the thresholds are in dBFS. |
| 10 | |
| 11 | /// A level as one of a few steps, for a meter that should move only when |
| 12 | /// the picture changes — not sixty times a second. |
| 13 | public enum MeterScale { |
| 14 | /// Bars on a meter. Step 0 is none lit. |
| 15 | public static let steps = 5 |
| 16 | /// The dBFS at which each bar lights. Speech peaks sit around -20 to |
| 17 | /// -6; a quiet room's floor around -60. The bottom bar lights for a |
| 18 | /// room tone, so a live microphone always shows *something*. |
| 19 | public static let thresholds: [Float] = [-60, -45, -32, -20, -10] |
| 20 | |
| 21 | public static func decibels(_ level: Float) -> Float { |
| 22 | level > 0 ? 20 * log10(level) : -.infinity |
| 23 | } |
| 24 | |
| 25 | /// 0 (nothing) to `steps` (loud). |
| 26 | public static func step(_ level: Float) -> Int { |
| 27 | let db = decibels(level) |
| 28 | return thresholds.filter { db >= $0 }.count |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | /// How long a condition has held, on the caller's clock. |
| 33 | public struct Held: Equatable, Sendable { |
| 34 | private var since: Double? |
| 35 | public init() {} |
| 36 | /// Seconds the condition has held as of `now`, or 0 when it does not. |
| 37 | public mutating func update(_ holding: Bool, now: Double) -> Double { |
| 38 | guard holding else { |
| 39 | since = nil |
| 40 | return 0 |
| 41 | } |
| 42 | if since == nil { since = now } |
| 43 | return now - (since ?? now) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// Whether someone is speaking, for the tile's highlight: on at a |
| 48 | /// speech-like level, off only once the level has fallen well below it |
| 49 | /// and stayed there a moment, so the highlight does not flicker between |
| 50 | /// syllables. |
| 51 | public struct SpeakingGate: Equatable, Sendable { |
| 52 | public static let onDb: Float = -30 |
| 53 | public static let offDb: Float = -42 |
| 54 | /// How long below `offDb` before the highlight goes. |
| 55 | public static let hangover: Double = 0.6 |
| 56 | public private(set) var speaking = false |
| 57 | private var quiet = Held() |
| 58 | public init() {} |
| 59 | |
| 60 | public mutating func update(level: Float, now: Double) -> Bool { |
| 61 | let db = MeterScale.decibels(level) |
| 62 | if db >= Self.onDb { |
| 63 | speaking = true |
| 64 | _ = quiet.update(false, now: now) |
| 65 | } else if speaking { |
| 66 | if quiet.update(db < Self.offDb, now: now) >= Self.hangover { speaking = false } |
| 67 | } |
| 68 | return speaking |
| 69 | } |
| 70 | |
| 71 | public mutating func reset() { |
| 72 | speaking = false |
| 73 | quiet = Held() |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | /// What a remote person's tile says about their sound. |
| 78 | public enum PeerAudioState: Equatable, Sendable { |
| 79 | /// They report themselves muted: the tile's existing Muted badge, and |
| 80 | /// no meter. |
| 81 | case muted |
| 82 | /// Their audio is arriving, they report themselves unmuted, and it has |
| 83 | /// been flat for `PeerAudioWatch.noSoundAfter` seconds. |
| 84 | case noSound |
| 85 | /// A level meter at this step. |
| 86 | case meter(Int) |
| 87 | } |
| 88 | |
| 89 | /// One reading of a remote person's audio, as the call measures it. |
| 90 | public struct PeerAudioSample: Equatable, Sendable { |
| 91 | /// Their own report (§15.19). Nil is unknown, which is not unmuted: |
| 92 | /// "No sound from" needs them to have said they are unmuted. |
| 93 | public var reported: PeerMedia? |
| 94 | /// The connection to them is up. |
| 95 | public var connected: Bool |
| 96 | /// Audio packets have arrived since the last look (bytes received rose). |
| 97 | public var arriving: Bool |
| 98 | /// The loudest decoded sample since the last reading, 0...1. |
| 99 | public var level: Float |
| 100 | |
| 101 | public init(reported: PeerMedia?, connected: Bool, arriving: Bool, level: Float) { |
| 102 | self.reported = reported |
| 103 | self.connected = connected |
| 104 | self.arriving = arriving |
| 105 | self.level = level |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | /// The receive side's rule, one per remote person. Precedence: muted, then |
| 110 | /// "No sound from", then the meter. |
| 111 | public struct PeerAudioWatch: Equatable, Sendable { |
| 112 | /// Below this peak the decoded audio is digital silence: a live |
| 113 | /// microphone in a quiet room, after a browser's own noise suppression, |
| 114 | /// still sits tens of dB above it. -85 dBFS is under two steps of a |
| 115 | /// 16-bit sample. |
| 116 | public static let silentDb: Float = -85 |
| 117 | /// "About five seconds" (spec §15.20). |
| 118 | public static let noSoundAfter: Double = 5 |
| 119 | |
| 120 | private var flat = Held() |
| 121 | private var gate = SpeakingGate() |
| 122 | public private(set) var state: PeerAudioState = .meter(0) |
| 123 | public private(set) var speaking = false |
| 124 | |
| 125 | public init() {} |
| 126 | |
| 127 | @discardableResult |
| 128 | public mutating func update(_ sample: PeerAudioSample, now: Double) -> PeerAudioState { |
| 129 | if sample.reported?.muted == true { |
| 130 | _ = flat.update(false, now: now) |
| 131 | gate.reset() |
| 132 | speaking = false |
| 133 | state = .muted |
| 134 | return state |
| 135 | } |
| 136 | let silent = MeterScale.decibels(sample.level) < Self.silentDb |
| 137 | let watching = sample.reported != nil && sample.connected && sample.arriving && silent |
| 138 | let held = flat.update(watching, now: now) |
| 139 | speaking = gate.update(level: sample.level, now: now) |
| 140 | state = held >= Self.noSoundAfter ? .noSound : .meter(MeterScale.step(sample.level)) |
| 141 | return state |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | /// One reading of this app's own microphone. |
| 146 | public struct MicSample: Equatable, Sendable { |
| 147 | public var muted: Bool |
| 148 | /// The microphone is open (see `CallAudio.capturing`). WebRTC opens it |
| 149 | /// only once there is someone to send to. |
| 150 | public var capturing: Bool |
| 151 | /// The loudest sample reaching the capture hook, before the noise |
| 152 | /// suppressor, since the last reading. The question "is the microphone |
| 153 | /// picking anything up" is asked of this: a quiet room the suppressor |
| 154 | /// has taken down can look dead afterwards and is not. |
| 155 | public var inputPeak: Float |
| 156 | /// Buffers through the hook since the last reading; none means nothing |
| 157 | /// flowed at all. |
| 158 | public var inputBuffers: Int |
| 159 | /// The loudest sample leaving the hook — what is sent — for the meter. |
| 160 | public var sentPeak: Float |
| 161 | /// At least one connection is up and carrying this microphone to |
| 162 | /// someone. Alone in a call there is none, WebRTC never opens the |
| 163 | /// microphone, and silence says nothing about it (Mac 0.4.6 said "Your |
| 164 | /// microphone isn't picking anything up" to Paul alone in a call). |
| 165 | public var sending: Bool |
| 166 | |
| 167 | public init( |
| 168 | muted: Bool, capturing: Bool, inputPeak: Float, inputBuffers: Int, sentPeak: Float, sending: Bool |
| 169 | ) { |
| 170 | self.muted = muted |
| 171 | self.capturing = capturing |
| 172 | self.inputPeak = inputPeak |
| 173 | self.inputBuffers = inputBuffers |
| 174 | self.sentPeak = sentPeak |
| 175 | self.sending = sending |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | /// The sending side's rule: the meter on your own tile, and "Your |
| 180 | /// microphone isn't picking anything up". |
| 181 | public struct MicWatch: Equatable, Sendable { |
| 182 | /// A microphone that is plugged in and on hears its own electrical |
| 183 | /// noise well above this; an empty input on an audio interface, or a |
| 184 | /// channel with nothing on it, does not. About one step of a 16-bit |
| 185 | /// sample. |
| 186 | public static let silentDb: Float = -90 |
| 187 | public static let silentAfter: Double = 5 |
| 188 | |
| 189 | private var flat = Held() |
| 190 | private var gate = SpeakingGate() |
| 191 | /// The hook has been handed audio while a connection was sending it: |
| 192 | /// the microphone really did start for someone. Until then there is |
| 193 | /// nothing to judge — alone, or before WebRTC has opened it. |
| 194 | private var started = false |
| 195 | public private(set) var step = 0 |
| 196 | public private(set) var silent = false |
| 197 | public private(set) var speaking = false |
| 198 | |
| 199 | public init() {} |
| 200 | |
| 201 | public mutating func update(_ sample: MicSample, now: Double) { |
| 202 | // Muted, WebRTC skips the hook, so there is nothing to measure and |
| 203 | // nothing to warn about: the Muted state already says it. |
| 204 | guard !sample.muted, sample.capturing else { |
| 205 | _ = flat.update(false, now: now) |
| 206 | gate.reset() |
| 207 | started = false |
| 208 | step = 0 |
| 209 | silent = false |
| 210 | speaking = false |
| 211 | return |
| 212 | } |
| 213 | // The warning needs both halves (2026-10-03): capture has started — |
| 214 | // the hook has had buffers while someone was being sent to — and a |
| 215 | // connection is still sending. Alone, nobody is sent to, so nobody |
| 216 | // is told their microphone is dead. |
| 217 | if !sample.sending { |
| 218 | started = false |
| 219 | } else if sample.inputBuffers > 0 { |
| 220 | started = true |
| 221 | } |
| 222 | let dead = |
| 223 | sample.inputBuffers == 0 || MeterScale.decibels(sample.inputPeak) < Self.silentDb |
| 224 | silent = flat.update(started && dead, now: now) >= Self.silentAfter |
| 225 | step = MeterScale.step(sample.sentPeak) |
| 226 | speaking = gate.update(level: sample.sentPeak, now: now) |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | /// The words, in one place: tiles, accessibility and the tests read them |
| 231 | /// from here. |
| 232 | public enum AudioWords { |
| 233 | public static func noSound(from name: String) -> String { "No sound from \(name)" } |
| 234 | public static let noSoundDetail = |
| 235 | "Their audio is arriving, but it has been silent for a few seconds while they are unmuted. Their microphone may be on the wrong input, turned down, or not the one they think they chose." |
| 236 | |
| 237 | public static let micSilent = "Your microphone isn’t picking anything up" |
| 238 | /// The ⓘ under "Your microphone isn't picking anything up": what is |
| 239 | /// being sent, from what, and where to change it. |
| 240 | public static func micSilentDetail(source: String, channels: Bool) -> String { |
| 241 | let what = source.isEmpty ? "your microphone" : source |
| 242 | var text = "Calls is sending what it hears from \(what), and that has been silent for five seconds, so others hear nothing." |
| 243 | if channels { |
| 244 | text += " If your microphone is plugged into a different input, choose that input under Devices." |
| 245 | } else { |
| 246 | text += " Check the microphone chosen under Devices, and that it is switched on and turned up." |
| 247 | } |
| 248 | return text |
| 249 | } |
| 250 | |
| 251 | public static func blocked(_ name: String) -> String { |
| 252 | "\(name)’s browser is blocking sound — they need to click to hear you." |
| 253 | } |
| 254 | public static func notHearingYou(_ name: String) -> String { |
| 255 | "\(name) isn’t getting any sound from you." |
| 256 | } |
| 257 | |
| 258 | /// What a person's tile tells you about their report on *you* (spec |
| 259 | /// §15.20): their browser refusing sound, and their not hearing you. |
| 260 | /// The second only while you are unmuted: muted, silence is expected. |
| 261 | public static func notices(name: String, media: PeerMedia?, me: String, muted: Bool) -> [String] { |
| 262 | guard let media else { return [] } |
| 263 | var out: [String] = [] |
| 264 | if media.audioBlocked == true { out.append(blocked(name)) } |
| 265 | if !muted, !me.isEmpty, media.noSoundFrom?.contains(me) == true { |
| 266 | out.append(notHearingYou(name)) |
| 267 | } |
| 268 | return out |
| 269 | } |
| 270 | |
| 271 | /// The sentence a tile adds to its accessibility label for its sound. |
| 272 | public static func accessibility(_ state: PeerAudioState, name: String) -> String? { |
| 273 | state == .noSound ? noSound(from: name) : nil |
| 274 | } |
| 275 | } |
| 276 | |