| 1 | import 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. |
| 8 | final 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 | |