oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import 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.
13public 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.
33public 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.
51public 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.
78public 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.
90public 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.
111public 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.
146public 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".
181public 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.
232public 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