import Foundation // Spec §15.20: what the meters show and when the "no sound" lines appear, // as rules the tests can hold with an injected clock. Nothing here touches // WebRTC or the main actor; `Call` feeds these from the capture hook // (CaptureLevel) and the per-peer receive tap (RemoteAudioTap). // // Levels are 0...1 of full scale throughout, as CaptureLevel reports // them, and the thresholds are in dBFS. /// A level as one of a few steps, for a meter that should move only when /// the picture changes — not sixty times a second. public enum MeterScale { /// Bars on a meter. Step 0 is none lit. public static let steps = 5 /// The dBFS at which each bar lights. Speech peaks sit around -20 to /// -6; a quiet room's floor around -60. The bottom bar lights for a /// room tone, so a live microphone always shows *something*. public static let thresholds: [Float] = [-60, -45, -32, -20, -10] public static func decibels(_ level: Float) -> Float { level > 0 ? 20 * log10(level) : -.infinity } /// 0 (nothing) to `steps` (loud). public static func step(_ level: Float) -> Int { let db = decibels(level) return thresholds.filter { db >= $0 }.count } } /// How long a condition has held, on the caller's clock. public struct Held: Equatable, Sendable { private var since: Double? public init() {} /// Seconds the condition has held as of `now`, or 0 when it does not. public mutating func update(_ holding: Bool, now: Double) -> Double { guard holding else { since = nil return 0 } if since == nil { since = now } return now - (since ?? now) } } /// Whether someone is speaking, for the tile's highlight: on at a /// speech-like level, off only once the level has fallen well below it /// and stayed there a moment, so the highlight does not flicker between /// syllables. public struct SpeakingGate: Equatable, Sendable { public static let onDb: Float = -30 public static let offDb: Float = -42 /// How long below `offDb` before the highlight goes. public static let hangover: Double = 0.6 public private(set) var speaking = false private var quiet = Held() public init() {} public mutating func update(level: Float, now: Double) -> Bool { let db = MeterScale.decibels(level) if db >= Self.onDb { speaking = true _ = quiet.update(false, now: now) } else if speaking { if quiet.update(db < Self.offDb, now: now) >= Self.hangover { speaking = false } } return speaking } public mutating func reset() { speaking = false quiet = Held() } } /// What a remote person's tile says about their sound. public enum PeerAudioState: Equatable, Sendable { /// They report themselves muted: the tile's existing Muted badge, and /// no meter. case muted /// Their audio is arriving, they report themselves unmuted, and it has /// been flat for `PeerAudioWatch.noSoundAfter` seconds. case noSound /// A level meter at this step. case meter(Int) } /// One reading of a remote person's audio, as the call measures it. public struct PeerAudioSample: Equatable, Sendable { /// Their own report (§15.19). Nil is unknown, which is not unmuted: /// "No sound from" needs them to have said they are unmuted. public var reported: PeerMedia? /// The connection to them is up. public var connected: Bool /// Audio packets have arrived since the last look (bytes received rose). public var arriving: Bool /// The loudest decoded sample since the last reading, 0...1. public var level: Float public init(reported: PeerMedia?, connected: Bool, arriving: Bool, level: Float) { self.reported = reported self.connected = connected self.arriving = arriving self.level = level } } /// The receive side's rule, one per remote person. Precedence: muted, then /// "No sound from", then the meter. public struct PeerAudioWatch: Equatable, Sendable { /// Below this peak the decoded audio is digital silence: a live /// microphone in a quiet room, after a browser's own noise suppression, /// still sits tens of dB above it. -85 dBFS is under two steps of a /// 16-bit sample. public static let silentDb: Float = -85 /// "About five seconds" (spec §15.20). public static let noSoundAfter: Double = 5 private var flat = Held() private var gate = SpeakingGate() public private(set) var state: PeerAudioState = .meter(0) public private(set) var speaking = false public init() {} @discardableResult public mutating func update(_ sample: PeerAudioSample, now: Double) -> PeerAudioState { if sample.reported?.muted == true { _ = flat.update(false, now: now) gate.reset() speaking = false state = .muted return state } let silent = MeterScale.decibels(sample.level) < Self.silentDb let watching = sample.reported != nil && sample.connected && sample.arriving && silent let held = flat.update(watching, now: now) speaking = gate.update(level: sample.level, now: now) state = held >= Self.noSoundAfter ? .noSound : .meter(MeterScale.step(sample.level)) return state } } /// One reading of this app's own microphone. public struct MicSample: Equatable, Sendable { public var muted: Bool /// The microphone is open (see `CallAudio.capturing`). WebRTC opens it /// only once there is someone to send to. public var capturing: Bool /// The loudest sample reaching the capture hook, before the noise /// suppressor, since the last reading. The question "is the microphone /// picking anything up" is asked of this: a quiet room the suppressor /// has taken down can look dead afterwards and is not. public var inputPeak: Float /// Buffers through the hook since the last reading; none means nothing /// flowed at all. public var inputBuffers: Int /// The loudest sample leaving the hook — what is sent — for the meter. public var sentPeak: Float /// At least one connection is up and carrying this microphone to /// someone. Alone in a call there is none, WebRTC never opens the /// microphone, and silence says nothing about it (Mac 0.4.6 said "Your /// microphone isn't picking anything up" to Paul alone in a call). public var sending: Bool public init( muted: Bool, capturing: Bool, inputPeak: Float, inputBuffers: Int, sentPeak: Float, sending: Bool ) { self.muted = muted self.capturing = capturing self.inputPeak = inputPeak self.inputBuffers = inputBuffers self.sentPeak = sentPeak self.sending = sending } } /// The sending side's rule: the meter on your own tile, and "Your /// microphone isn't picking anything up". public struct MicWatch: Equatable, Sendable { /// A microphone that is plugged in and on hears its own electrical /// noise well above this; an empty input on an audio interface, or a /// channel with nothing on it, does not. About one step of a 16-bit /// sample. public static let silentDb: Float = -90 public static let silentAfter: Double = 5 private var flat = Held() private var gate = SpeakingGate() /// The hook has been handed audio while a connection was sending it: /// the microphone really did start for someone. Until then there is /// nothing to judge — alone, or before WebRTC has opened it. private var started = false public private(set) var step = 0 public private(set) var silent = false public private(set) var speaking = false public init() {} public mutating func update(_ sample: MicSample, now: Double) { // Muted, WebRTC skips the hook, so there is nothing to measure and // nothing to warn about: the Muted state already says it. guard !sample.muted, sample.capturing else { _ = flat.update(false, now: now) gate.reset() started = false step = 0 silent = false speaking = false return } // The warning needs both halves (2026-10-03): capture has started — // the hook has had buffers while someone was being sent to — and a // connection is still sending. Alone, nobody is sent to, so nobody // is told their microphone is dead. if !sample.sending { started = false } else if sample.inputBuffers > 0 { started = true } let dead = sample.inputBuffers == 0 || MeterScale.decibels(sample.inputPeak) < Self.silentDb silent = flat.update(started && dead, now: now) >= Self.silentAfter step = MeterScale.step(sample.sentPeak) speaking = gate.update(level: sample.sentPeak, now: now) } } /// The words, in one place: tiles, accessibility and the tests read them /// from here. public enum AudioWords { public static func noSound(from name: String) -> String { "No sound from \(name)" } public static let noSoundDetail = "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." public static let micSilent = "Your microphone isn’t picking anything up" /// The ⓘ under "Your microphone isn't picking anything up": what is /// being sent, from what, and where to change it. public static func micSilentDetail(source: String, channels: Bool) -> String { let what = source.isEmpty ? "your microphone" : source var text = "Calls is sending what it hears from \(what), and that has been silent for five seconds, so others hear nothing." if channels { text += " If your microphone is plugged into a different input, choose that input under Devices." } else { text += " Check the microphone chosen under Devices, and that it is switched on and turned up." } return text } public static func blocked(_ name: String) -> String { "\(name)’s browser is blocking sound — they need to click to hear you." } public static func notHearingYou(_ name: String) -> String { "\(name) isn’t getting any sound from you." } /// What a person's tile tells you about their report on *you* (spec /// §15.20): their browser refusing sound, and their not hearing you. /// The second only while you are unmuted: muted, silence is expected. public static func notices(name: String, media: PeerMedia?, me: String, muted: Bool) -> [String] { guard let media else { return [] } var out: [String] = [] if media.audioBlocked == true { out.append(blocked(name)) } if !muted, !me.isEmpty, media.noSoundFrom?.contains(me) == true { out.append(notHearingYou(name)) } return out } /// The sentence a tile adds to its accessibility label for its sound. public static func accessibility(_ state: PeerAudioState, name: String) -> String? { state == .noSound ? noSound(from: name) : nil } }