oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import XCTest
2
3@testable import CallsKit
4
5/// Spec §15.20's rules on the native side, on a clock the tests own: the
6/// meter's steps, the five-second "no sound" timers, what wins when a
7/// person is muted, silent and reporting on you all at once, and the words.
8final class AudioLevelTests: XCTestCase {
9 private func level(db: Float) -> Float { pow(10, db / 20) }
10
11 // MARK: Meter
12
13 func testTheMeterStepsByDecibels() {
14 XCTAssertEqual(MeterScale.step(0), 0)
15 XCTAssertEqual(MeterScale.step(level(db: -90)), 0, "digital silence lights nothing")
16 XCTAssertEqual(MeterScale.step(level(db: -55)), 1, "a room's tone lights the bottom bar")
17 XCTAssertEqual(MeterScale.step(level(db: -40)), 2)
18 XCTAssertEqual(MeterScale.step(level(db: -25)), 3)
19 XCTAssertEqual(MeterScale.step(level(db: -15)), 4)
20 XCTAssertEqual(MeterScale.step(1), MeterScale.steps, "full scale lights every bar")
21 // Monotonic: louder never shows fewer bars.
22 var last = 0
23 for db in stride(from: Float(-100), through: 0, by: 1) {
24 let step = MeterScale.step(level(db: db))
25 XCTAssertGreaterThanOrEqual(step, last)
26 last = step
27 }
28 }
29
30 func testHeldCountsFromWhenTheConditionStarted() {
31 var held = Held()
32 XCTAssertEqual(held.update(true, now: 10), 0)
33 XCTAssertEqual(held.update(true, now: 13), 3)
34 XCTAssertEqual(held.update(false, now: 14), 0)
35 XCTAssertEqual(held.update(true, now: 20), 0, "a break starts the count again")
36 }
37
38 func testSpeakingHasHysteresisAndAHangover() {
39 var gate = SpeakingGate()
40 XCTAssertFalse(gate.update(level: level(db: -36), now: 0), "between the thresholds: not yet")
41 XCTAssertTrue(gate.update(level: level(db: -20), now: 0.2))
42 XCTAssertTrue(gate.update(level: level(db: -36), now: 0.4), "between the thresholds: still")
43 XCTAssertTrue(gate.update(level: level(db: -60), now: 0.6), "a gap between syllables")
44 XCTAssertTrue(gate.update(level: level(db: -60), now: 1.0))
45 XCTAssertFalse(gate.update(level: level(db: -60), now: 1.3), "quiet past the hangover")
46 }
47
48 // MARK: Receive side
49
50 private let unmuted = PeerMedia(muted: false, cameraOff: false)
51
52 private func sample(
53 _ media: PeerMedia?, db: Float = -120, connected: Bool = true, arriving: Bool = true
54 ) -> PeerAudioSample {
55 PeerAudioSample(reported: media, connected: connected, arriving: arriving, level: level(db: db))
56 }
57
58 func testNoSoundAfterFiveFlatSecondsWhileTheyAreUnmuted() {
59 var watch = PeerAudioWatch()
60 for t in stride(from: 0.0, through: 4.8, by: 0.2) {
61 XCTAssertEqual(watch.update(sample(unmuted), now: t), .meter(0), "at \(t)s")
62 }
63 XCTAssertEqual(watch.update(sample(unmuted), now: 5.0), .noSound)
64 XCTAssertEqual(watch.update(sample(unmuted, db: -30), now: 5.2), .meter(3), "sound clears it at once")
65 XCTAssertEqual(watch.update(sample(unmuted), now: 5.4), .meter(0), "and the count starts again")
66 XCTAssertEqual(watch.update(sample(unmuted), now: 10.2), .meter(0))
67 XCTAssertEqual(watch.update(sample(unmuted), now: 10.5), .noSound)
68 }
69
70 func testMutedWinsOverEverything() {
71 var watch = PeerAudioWatch()
72 let muted = PeerMedia(muted: true, cameraOff: false)
73 for t in stride(from: 0.0, through: 10, by: 0.5) {
74 XCTAssertEqual(watch.update(sample(muted), now: t), .muted)
75 }
76 XCTAssertFalse(watch.speaking)
77 // Unmuting starts the five seconds from then, not from before.
78 XCTAssertEqual(watch.update(sample(unmuted), now: 10.5), .meter(0))
79 XCTAssertEqual(watch.update(sample(unmuted), now: 14), .meter(0))
80 XCTAssertEqual(watch.update(sample(unmuted), now: 15.5), .noSound)
81 }
82
83 func testNoSoundNeedsAReportConnectionAndPackets() {
84 // Unknown is not "reporting themselves unmuted" (a client from before
85 // §15.19): a meter, never the sentence.
86 var unknown = PeerAudioWatch()
87 var down = PeerAudioWatch()
88 var stalled = PeerAudioWatch()
89 for t in stride(from: 0.0, through: 8, by: 0.2) {
90 XCTAssertEqual(unknown.update(sample(nil), now: t), .meter(0))
91 XCTAssertEqual(down.update(sample(unmuted, connected: false), now: t), .meter(0))
92 XCTAssertEqual(stalled.update(sample(unmuted, arriving: false), now: t), .meter(0))
93 }
94 }
95
96 func testQuietButLiveIsNotSilence() {
97 // A live microphone in a quiet room, after a browser's suppression:
98 // far above the silence threshold, however long it lasts.
99 var watch = PeerAudioWatch()
100 for t in stride(from: 0.0, through: 20, by: 0.2) {
101 XCTAssertNotEqual(watch.update(sample(unmuted, db: -70), now: t), .noSound)
102 }
103 }
104
105 // MARK: Send side
106
107 private func mic(
108 muted: Bool = false, capturing: Bool = true, inputDb: Float = -120, buffers: Int = 10,
109 sentDb: Float = -120, sending: Bool = true
110 ) -> MicSample {
111 MicSample(
112 muted: muted, capturing: capturing, inputPeak: level(db: inputDb), inputBuffers: buffers,
113 sentPeak: level(db: sentDb), sending: sending)
114 }
115
116 func testTheMicrophoneWarningComesAfterFiveDeadSeconds() {
117 var watch = MicWatch()
118 for t in stride(from: 0.0, through: 4.9, by: 0.1) {
119 watch.update(mic(), now: t)
120 XCTAssertFalse(watch.silent, "at \(t)s")
121 }
122 watch.update(mic(), now: 5.0)
123 XCTAssertTrue(watch.silent)
124 watch.update(mic(inputDb: -60, sentDb: -75), now: 5.1)
125 XCTAssertFalse(watch.silent, "any sound on the input clears it")
126 }
127
128 func testBuffersStoppingIsDeadToo() {
129 // Capture started, then nothing reached the hook at all: a device
130 // that went away under the call.
131 var watch = MicWatch()
132 watch.update(mic(inputDb: -20), now: 0)
133 for t in stride(from: 0.1, through: 5.2, by: 0.1) {
134 watch.update(mic(inputDb: -20, buffers: 0), now: t)
135 }
136 XCTAssertTrue(watch.silent)
137 }
138
139 func testAloneNeverWarns() {
140 // Mac 0.4.6, 2026-10-03: alone in a call ("Waiting for others") no
141 // connection sends, WebRTC never opens the microphone, and the hook
142 // reads nothing — which is not a dead microphone.
143 var never = MicWatch()
144 var stale = MicWatch()
145 for t in stride(from: 0.0, through: 30, by: 0.1) {
146 never.update(mic(buffers: 0, sending: false), now: t)
147 // And alone again after someone left: the module may still say it
148 // is recording, the hook has had buffers before, and silence now.
149 stale.update(mic(buffers: t < 1 ? 10 : 0, sending: false), now: t)
150 XCTAssertFalse(never.silent, "alone, at \(t)s")
151 XCTAssertFalse(stale.silent, "alone again, at \(t)s")
152 }
153 }
154
155 func testWithSomeoneAndASilentMicrophoneItWarnsAfterFiveSeconds() {
156 var watch = MicWatch()
157 // Alone for a while: nothing.
158 for t in stride(from: 0.0, through: 20, by: 0.1) {
159 watch.update(mic(buffers: 0, sending: false), now: t)
160 }
161 // Someone joins; the connection is up before WebRTC has opened the
162 // microphone, and that wait is not counted.
163 for t in stride(from: 20.1, through: 27, by: 0.1) {
164 watch.update(mic(buffers: 0, sending: true), now: t)
165 XCTAssertFalse(watch.silent, "capture not started, at \(t)s")
166 }
167 // Capture starts, and is flat: five seconds later, the warning.
168 for t in stride(from: 27.1, through: 32.0, by: 0.1) {
169 watch.update(mic(sending: true), now: t)
170 XCTAssertFalse(watch.silent, "at \(t)s")
171 }
172 watch.update(mic(sending: true), now: 32.2)
173 XCTAssertTrue(watch.silent)
174 // They leave: the warning goes at once.
175 watch.update(mic(sending: false), now: 32.3)
176 XCTAssertFalse(watch.silent)
177 }
178
179 func testTheWarningAsksThePreSuppressorLevel() {
180 // The suppressor can take a quiet room to near silence; the input
181 // before it is what says whether the microphone hears anything.
182 var watch = MicWatch()
183 for t in stride(from: 0.0, through: 8, by: 0.1) {
184 watch.update(mic(inputDb: -75, sentDb: -120), now: t)
185 }
186 XCTAssertFalse(watch.silent)
187 XCTAssertEqual(watch.step, 0)
188 }
189
190 func testMutedOrNotCapturingNeverWarns() {
191 var muted = MicWatch()
192 var idle = MicWatch()
193 for t in stride(from: 0.0, through: 10, by: 0.1) {
194 muted.update(mic(muted: true, buffers: 0), now: t)
195 idle.update(mic(capturing: false, buffers: 0), now: t)
196 }
197 XCTAssertFalse(muted.silent)
198 XCTAssertFalse(idle.silent)
199 // Unmuting starts the count afresh.
200 muted.update(mic(), now: 10.1)
201 muted.update(mic(), now: 14)
202 XCTAssertFalse(muted.silent)
203 muted.update(mic(), now: 15.2)
204 XCTAssertTrue(muted.silent)
205 }
206
207 func testTheSelfMeterShowsWhatIsSent() {
208 var watch = MicWatch()
209 watch.update(mic(inputDb: -20, sentDb: -15), now: 0)
210 XCTAssertEqual(watch.step, 4)
211 XCTAssertTrue(watch.speaking)
212 }
213
214 // MARK: Words
215
216 func testNoticesSayWhoAndWhat() {
217 let both = PeerMedia(muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: ["me", "x"])
218 XCTAssertEqual(
219 AudioWords.notices(name: "Tony", media: both, me: "me", muted: false),
220 [
221 "Tony’s browser is blocking sound — they need to click to hear you.",
222 "Tony isn’t getting any sound from you.",
223 ])
224 XCTAssertEqual(
225 AudioWords.notices(name: "Tony", media: both, me: "me", muted: true),
226 ["Tony’s browser is blocking sound — they need to click to hear you."],
227 "muted, silence from you is expected")
228 XCTAssertEqual(
229 AudioWords.notices(name: "Tony", media: both, me: "other", muted: false),
230 ["Tony’s browser is blocking sound — they need to click to hear you."],
231 "someone else's silence is not yours")
232 XCTAssertEqual(AudioWords.notices(name: "Tony", media: unmuted, me: "me", muted: false), [])
233 XCTAssertEqual(AudioWords.notices(name: "Tony", media: nil, me: "me", muted: false), [])
234 }
235
236 func testTheWarningNamesTheDeviceAndChannel() {
237 let text = AudioWords.micSilentDetail(source: "Scarlett 18i8 USB, input 1", channels: true)
238 XCTAssertTrue(text.contains("Scarlett 18i8 USB, input 1"))
239 XCTAssertTrue(text.contains("choose that input under Devices"))
240 XCTAssertFalse(
241 AudioWords.micSilentDetail(source: "MacBook Pro Microphone", channels: false).contains("input"))
242 }
243
244 @MainActor func testTheTileSaysItsSoundInWords() {
245 let peer = PeerAudio()
246 peer.show(state: .noSound, speaking: false, notices: [AudioWords.notHearingYou("Bea")])
247 let info = TileAudioInfo.peer(peer, name: "Bea")
248 XCTAssertEqual(info.status, .noSound("Bea"))
249 XCTAssertEqual(
250 tileAccessibilityLabel("Bea", muted: false, audio: info),
251 "Bea, No sound from Bea, Bea isn’t getting any sound from you.")
252 XCTAssertEqual(info.detail, AudioWords.noSoundDetail)
253
254 peer.show(state: .muted, speaking: false, notices: [])
255 let muted = TileAudioInfo.peer(peer, name: "Bea")
256 XCTAssertEqual(muted.status, .none, "muted: the badge, and no glow")
257 XCTAssertEqual(tileAccessibilityLabel("Bea", muted: true, audio: muted), "Bea, muted")
258
259 let you = MicAudio()
260 you.show(step: 0, muted: false, speaking: false, silent: true, source: "Scarlett", channels: true)
261 XCTAssertEqual(TileAudioInfo.you(you).notices, [AudioWords.micSilent])
262 }
263
264 @MainActor func testLevelsReportWhoHasGoneQuietAndForgetWhoLeft() {
265 let levels = AudioLevels()
266 for t in stride(from: 0.0, through: 5.2, by: 0.2) {
267 levels.updatePeer("bea", name: "Bea", sample: sample(unmuted), notices: [], now: t)
268 levels.updatePeer("cy", name: "Cy", sample: sample(unmuted, db: -20), notices: [], now: t)
269 levels.settle(present: ["bea", "cy"])
270 }
271 XCTAssertEqual(levels.noSoundFrom, ["bea"])
272 XCTAssertEqual(levels.peer("bea").state, .noSound)
273 XCTAssertTrue(levels.anyoneSpeaking.value)
274 levels.settle(present: ["cy"])
275 XCTAssertEqual(levels.noSoundFrom, [], "someone who left is no longer reported")
276 XCTAssertEqual(levels.peer("bea").state, .meter(0))
277 }
278}
279