import XCTest @testable import CallsKit /// Spec §15.20's rules on the native side, on a clock the tests own: the /// meter's steps, the five-second "no sound" timers, what wins when a /// person is muted, silent and reporting on you all at once, and the words. final class AudioLevelTests: XCTestCase { private func level(db: Float) -> Float { pow(10, db / 20) } // MARK: Meter func testTheMeterStepsByDecibels() { XCTAssertEqual(MeterScale.step(0), 0) XCTAssertEqual(MeterScale.step(level(db: -90)), 0, "digital silence lights nothing") XCTAssertEqual(MeterScale.step(level(db: -55)), 1, "a room's tone lights the bottom bar") XCTAssertEqual(MeterScale.step(level(db: -40)), 2) XCTAssertEqual(MeterScale.step(level(db: -25)), 3) XCTAssertEqual(MeterScale.step(level(db: -15)), 4) XCTAssertEqual(MeterScale.step(1), MeterScale.steps, "full scale lights every bar") // Monotonic: louder never shows fewer bars. var last = 0 for db in stride(from: Float(-100), through: 0, by: 1) { let step = MeterScale.step(level(db: db)) XCTAssertGreaterThanOrEqual(step, last) last = step } } func testHeldCountsFromWhenTheConditionStarted() { var held = Held() XCTAssertEqual(held.update(true, now: 10), 0) XCTAssertEqual(held.update(true, now: 13), 3) XCTAssertEqual(held.update(false, now: 14), 0) XCTAssertEqual(held.update(true, now: 20), 0, "a break starts the count again") } func testSpeakingHasHysteresisAndAHangover() { var gate = SpeakingGate() XCTAssertFalse(gate.update(level: level(db: -36), now: 0), "between the thresholds: not yet") XCTAssertTrue(gate.update(level: level(db: -20), now: 0.2)) XCTAssertTrue(gate.update(level: level(db: -36), now: 0.4), "between the thresholds: still") XCTAssertTrue(gate.update(level: level(db: -60), now: 0.6), "a gap between syllables") XCTAssertTrue(gate.update(level: level(db: -60), now: 1.0)) XCTAssertFalse(gate.update(level: level(db: -60), now: 1.3), "quiet past the hangover") } // MARK: Receive side private let unmuted = PeerMedia(muted: false, cameraOff: false) private func sample( _ media: PeerMedia?, db: Float = -120, connected: Bool = true, arriving: Bool = true ) -> PeerAudioSample { PeerAudioSample(reported: media, connected: connected, arriving: arriving, level: level(db: db)) } func testNoSoundAfterFiveFlatSecondsWhileTheyAreUnmuted() { var watch = PeerAudioWatch() for t in stride(from: 0.0, through: 4.8, by: 0.2) { XCTAssertEqual(watch.update(sample(unmuted), now: t), .meter(0), "at \(t)s") } XCTAssertEqual(watch.update(sample(unmuted), now: 5.0), .noSound) XCTAssertEqual(watch.update(sample(unmuted, db: -30), now: 5.2), .meter(3), "sound clears it at once") XCTAssertEqual(watch.update(sample(unmuted), now: 5.4), .meter(0), "and the count starts again") XCTAssertEqual(watch.update(sample(unmuted), now: 10.2), .meter(0)) XCTAssertEqual(watch.update(sample(unmuted), now: 10.5), .noSound) } func testMutedWinsOverEverything() { var watch = PeerAudioWatch() let muted = PeerMedia(muted: true, cameraOff: false) for t in stride(from: 0.0, through: 10, by: 0.5) { XCTAssertEqual(watch.update(sample(muted), now: t), .muted) } XCTAssertFalse(watch.speaking) // Unmuting starts the five seconds from then, not from before. XCTAssertEqual(watch.update(sample(unmuted), now: 10.5), .meter(0)) XCTAssertEqual(watch.update(sample(unmuted), now: 14), .meter(0)) XCTAssertEqual(watch.update(sample(unmuted), now: 15.5), .noSound) } func testNoSoundNeedsAReportConnectionAndPackets() { // Unknown is not "reporting themselves unmuted" (a client from before // §15.19): a meter, never the sentence. var unknown = PeerAudioWatch() var down = PeerAudioWatch() var stalled = PeerAudioWatch() for t in stride(from: 0.0, through: 8, by: 0.2) { XCTAssertEqual(unknown.update(sample(nil), now: t), .meter(0)) XCTAssertEqual(down.update(sample(unmuted, connected: false), now: t), .meter(0)) XCTAssertEqual(stalled.update(sample(unmuted, arriving: false), now: t), .meter(0)) } } func testQuietButLiveIsNotSilence() { // A live microphone in a quiet room, after a browser's suppression: // far above the silence threshold, however long it lasts. var watch = PeerAudioWatch() for t in stride(from: 0.0, through: 20, by: 0.2) { XCTAssertNotEqual(watch.update(sample(unmuted, db: -70), now: t), .noSound) } } // MARK: Send side private func mic( muted: Bool = false, capturing: Bool = true, inputDb: Float = -120, buffers: Int = 10, sentDb: Float = -120, sending: Bool = true ) -> MicSample { MicSample( muted: muted, capturing: capturing, inputPeak: level(db: inputDb), inputBuffers: buffers, sentPeak: level(db: sentDb), sending: sending) } func testTheMicrophoneWarningComesAfterFiveDeadSeconds() { var watch = MicWatch() for t in stride(from: 0.0, through: 4.9, by: 0.1) { watch.update(mic(), now: t) XCTAssertFalse(watch.silent, "at \(t)s") } watch.update(mic(), now: 5.0) XCTAssertTrue(watch.silent) watch.update(mic(inputDb: -60, sentDb: -75), now: 5.1) XCTAssertFalse(watch.silent, "any sound on the input clears it") } func testBuffersStoppingIsDeadToo() { // Capture started, then nothing reached the hook at all: a device // that went away under the call. var watch = MicWatch() watch.update(mic(inputDb: -20), now: 0) for t in stride(from: 0.1, through: 5.2, by: 0.1) { watch.update(mic(inputDb: -20, buffers: 0), now: t) } XCTAssertTrue(watch.silent) } func testAloneNeverWarns() { // Mac 0.4.6, 2026-10-03: alone in a call ("Waiting for others") no // connection sends, WebRTC never opens the microphone, and the hook // reads nothing — which is not a dead microphone. var never = MicWatch() var stale = MicWatch() for t in stride(from: 0.0, through: 30, by: 0.1) { never.update(mic(buffers: 0, sending: false), now: t) // And alone again after someone left: the module may still say it // is recording, the hook has had buffers before, and silence now. stale.update(mic(buffers: t < 1 ? 10 : 0, sending: false), now: t) XCTAssertFalse(never.silent, "alone, at \(t)s") XCTAssertFalse(stale.silent, "alone again, at \(t)s") } } func testWithSomeoneAndASilentMicrophoneItWarnsAfterFiveSeconds() { var watch = MicWatch() // Alone for a while: nothing. for t in stride(from: 0.0, through: 20, by: 0.1) { watch.update(mic(buffers: 0, sending: false), now: t) } // Someone joins; the connection is up before WebRTC has opened the // microphone, and that wait is not counted. for t in stride(from: 20.1, through: 27, by: 0.1) { watch.update(mic(buffers: 0, sending: true), now: t) XCTAssertFalse(watch.silent, "capture not started, at \(t)s") } // Capture starts, and is flat: five seconds later, the warning. for t in stride(from: 27.1, through: 32.0, by: 0.1) { watch.update(mic(sending: true), now: t) XCTAssertFalse(watch.silent, "at \(t)s") } watch.update(mic(sending: true), now: 32.2) XCTAssertTrue(watch.silent) // They leave: the warning goes at once. watch.update(mic(sending: false), now: 32.3) XCTAssertFalse(watch.silent) } func testTheWarningAsksThePreSuppressorLevel() { // The suppressor can take a quiet room to near silence; the input // before it is what says whether the microphone hears anything. var watch = MicWatch() for t in stride(from: 0.0, through: 8, by: 0.1) { watch.update(mic(inputDb: -75, sentDb: -120), now: t) } XCTAssertFalse(watch.silent) XCTAssertEqual(watch.step, 0) } func testMutedOrNotCapturingNeverWarns() { var muted = MicWatch() var idle = MicWatch() for t in stride(from: 0.0, through: 10, by: 0.1) { muted.update(mic(muted: true, buffers: 0), now: t) idle.update(mic(capturing: false, buffers: 0), now: t) } XCTAssertFalse(muted.silent) XCTAssertFalse(idle.silent) // Unmuting starts the count afresh. muted.update(mic(), now: 10.1) muted.update(mic(), now: 14) XCTAssertFalse(muted.silent) muted.update(mic(), now: 15.2) XCTAssertTrue(muted.silent) } func testTheSelfMeterShowsWhatIsSent() { var watch = MicWatch() watch.update(mic(inputDb: -20, sentDb: -15), now: 0) XCTAssertEqual(watch.step, 4) XCTAssertTrue(watch.speaking) } // MARK: Words func testNoticesSayWhoAndWhat() { let both = PeerMedia(muted: false, cameraOff: false, audioBlocked: true, noSoundFrom: ["me", "x"]) XCTAssertEqual( AudioWords.notices(name: "Tony", media: both, me: "me", muted: false), [ "Tony’s browser is blocking sound — they need to click to hear you.", "Tony isn’t getting any sound from you.", ]) XCTAssertEqual( AudioWords.notices(name: "Tony", media: both, me: "me", muted: true), ["Tony’s browser is blocking sound — they need to click to hear you."], "muted, silence from you is expected") XCTAssertEqual( AudioWords.notices(name: "Tony", media: both, me: "other", muted: false), ["Tony’s browser is blocking sound — they need to click to hear you."], "someone else's silence is not yours") XCTAssertEqual(AudioWords.notices(name: "Tony", media: unmuted, me: "me", muted: false), []) XCTAssertEqual(AudioWords.notices(name: "Tony", media: nil, me: "me", muted: false), []) } func testTheWarningNamesTheDeviceAndChannel() { let text = AudioWords.micSilentDetail(source: "Scarlett 18i8 USB, input 1", channels: true) XCTAssertTrue(text.contains("Scarlett 18i8 USB, input 1")) XCTAssertTrue(text.contains("choose that input under Devices")) XCTAssertFalse( AudioWords.micSilentDetail(source: "MacBook Pro Microphone", channels: false).contains("input")) } @MainActor func testTheTileSaysItsSoundInWords() { let peer = PeerAudio() peer.show(state: .noSound, speaking: false, notices: [AudioWords.notHearingYou("Bea")]) let info = TileAudioInfo.peer(peer, name: "Bea") XCTAssertEqual(info.status, .noSound("Bea")) XCTAssertEqual( tileAccessibilityLabel("Bea", muted: false, audio: info), "Bea, No sound from Bea, Bea isn’t getting any sound from you.") XCTAssertEqual(info.detail, AudioWords.noSoundDetail) peer.show(state: .muted, speaking: false, notices: []) let muted = TileAudioInfo.peer(peer, name: "Bea") XCTAssertEqual(muted.status, .none, "muted: the badge, and no glow") XCTAssertEqual(tileAccessibilityLabel("Bea", muted: true, audio: muted), "Bea, muted") let you = MicAudio() you.show(step: 0, muted: false, speaking: false, silent: true, source: "Scarlett", channels: true) XCTAssertEqual(TileAudioInfo.you(you).notices, [AudioWords.micSilent]) } @MainActor func testLevelsReportWhoHasGoneQuietAndForgetWhoLeft() { let levels = AudioLevels() for t in stride(from: 0.0, through: 5.2, by: 0.2) { levels.updatePeer("bea", name: "Bea", sample: sample(unmuted), notices: [], now: t) levels.updatePeer("cy", name: "Cy", sample: sample(unmuted, db: -20), notices: [], now: t) levels.settle(present: ["bea", "cy"]) } XCTAssertEqual(levels.noSoundFrom, ["bea"]) XCTAssertEqual(levels.peer("bea").state, .noSound) XCTAssertTrue(levels.anyoneSpeaking.value) levels.settle(present: ["cy"]) XCTAssertEqual(levels.noSoundFrom, [], "someone who left is no longer reported") XCTAssertEqual(levels.peer("bea").state, .meter(0)) } }