| 1 | import XCTest |
| 2 | |
| 3 | @_spi(Fixtures) @testable import CallsKit |
| 4 | |
| 5 | /// The speaking glow's rule (0.3.7) on a clock the tests own: what lights |
| 6 | /// it and what does not, how quickly it rises and falls, silence, and |
| 7 | /// Reduce Motion. |
| 8 | final class SpeakingGlowTests: XCTestCase { |
| 9 | private func level(db: Float) -> Float { pow(10, db / 20) } |
| 10 | |
| 11 | /// Feeds a gate and a glow the same readings, as AudioLevels does. |
| 12 | private struct Voice { |
| 13 | var gate = SpeakingGate() |
| 14 | var glow = SpeakingGlow() |
| 15 | mutating func hear(db: Float, at now: Double) { |
| 16 | let level = db.isFinite ? pow(10, db / 20) : 0 |
| 17 | let speaking = gate.update(level: level, now: now) |
| 18 | glow.update(level: level, speaking: speaking, now: now) |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | func testBrightnessFollowsDecibels() { |
| 23 | XCTAssertEqual(SpeakingGlow.intensity(level: 0), 0) |
| 24 | XCTAssertEqual(SpeakingGlow.intensity(level: level(db: -60)), 0) |
| 25 | XCTAssertEqual(SpeakingGlow.intensity(level: level(db: SpeakingGlow.quietDb)), 0, accuracy: 1e-6) |
| 26 | XCTAssertEqual(SpeakingGlow.intensity(level: level(db: -27)), 0.5, accuracy: 1e-3) |
| 27 | XCTAssertEqual(SpeakingGlow.intensity(level: level(db: SpeakingGlow.loudDb)), 1, accuracy: 1e-6) |
| 28 | XCTAssertEqual(SpeakingGlow.intensity(level: 1), 1) |
| 29 | var last = 0.0 |
| 30 | for db in stride(from: Float(-90), through: 0, by: 1) { |
| 31 | let x = SpeakingGlow.intensity(level: level(db: db)) |
| 32 | XCTAssertGreaterThanOrEqual(x, last, "louder is never dimmer") |
| 33 | last = x |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func testSilenceIsNoGlow() { |
| 38 | var voice = Voice() |
| 39 | for t in stride(from: 0.0, through: 5, by: 0.1) { |
| 40 | voice.hear(db: -.infinity, at: t) |
| 41 | XCTAssertFalse(voice.glow.lit) |
| 42 | XCTAssertEqual(voice.glow.value(at: t), 0) |
| 43 | XCTAssertEqual(voice.glow.value(at: t, reduceMotion: true), 0) |
| 44 | } |
| 45 | // A quiet room's tone, under the speaking level, is not speech either. |
| 46 | for t in stride(from: 5.0, through: 10, by: 0.1) { voice.hear(db: -55, at: t) } |
| 47 | XCTAssertFalse(voice.glow.lit) |
| 48 | } |
| 49 | |
| 50 | func testABriefNoiseDoesNotLightIt() { |
| 51 | // One loud reading — a click, a cough, a door — then quiet. The gate |
| 52 | // calls that speaking for its hangover; the glow waits for more. |
| 53 | var voice = Voice() |
| 54 | voice.hear(db: -8, at: 0) |
| 55 | XCTAssertTrue(voice.gate.speaking, "the gate is on at once") |
| 56 | for t in stride(from: 0.1, through: 2, by: 0.1) { |
| 57 | voice.hear(db: -70, at: t) |
| 58 | XCTAssertFalse(voice.glow.lit, "at \(t)s") |
| 59 | XCTAssertEqual(voice.glow.value(at: t), 0) |
| 60 | } |
| 61 | // Two clicks a second and a half apart are two brief noises, not a |
| 62 | // voice, read at the peers' five times a second. |
| 63 | voice.hear(db: -8, at: 3) |
| 64 | for t in stride(from: 3.2, through: 4.2, by: 0.2) { voice.hear(db: -70, at: t) } |
| 65 | voice.hear(db: -8, at: 4.4) |
| 66 | for t in stride(from: 4.6, through: 5.4, by: 0.2) { |
| 67 | voice.hear(db: -70, at: t) |
| 68 | XCTAssertFalse(voice.glow.lit, "at \(t)s") |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func testSpeechLightsItOnceItKeepsUp() { |
| 73 | var voice = Voice() |
| 74 | voice.hear(db: -20, at: 0) |
| 75 | XCTAssertFalse(voice.glow.lit, "one reading is not yet a voice") |
| 76 | voice.hear(db: -20, at: 0.1) |
| 77 | XCTAssertFalse(voice.glow.lit) |
| 78 | voice.hear(db: -20, at: 0.2) |
| 79 | XCTAssertTrue(voice.glow.lit, "sound at a speaking level for \(SpeakingGlow.onset)s is") |
| 80 | XCTAssertEqual(voice.glow.target, SpeakingGlow.intensity(level: level(db: -20)), accuracy: 1e-9) |
| 81 | // Between syllables its level falls away but it stays lit, as the |
| 82 | // gate does: the rim and inner light hold at their least. |
| 83 | voice.hear(db: -45, at: 0.3) |
| 84 | XCTAssertTrue(voice.glow.lit) |
| 85 | XCTAssertEqual(voice.glow.target, 0, accuracy: 1e-9) |
| 86 | XCTAssertTrue(voice.glow.on(at: 5)) |
| 87 | // And goes out when the gate lets go. |
| 88 | for t in stride(from: 0.4, through: 1.2, by: 0.1) { voice.hear(db: -70, at: t) } |
| 89 | XCTAssertFalse(voice.glow.lit) |
| 90 | XCTAssertEqual(voice.glow.target, 0) |
| 91 | } |
| 92 | |
| 93 | func testItRisesQuicklyAndFallsSlowly() { |
| 94 | var glow = SpeakingGlow() |
| 95 | let loud = level(db: -12) |
| 96 | glow.update(level: loud, speaking: true, now: 0) |
| 97 | glow.update(level: loud, speaking: true, now: 0.15) |
| 98 | XCTAssertTrue(glow.lit) |
| 99 | XCTAssertEqual(glow.value(at: 0.15), 0, accuracy: 1e-9, "from where it was") |
| 100 | // Attack: 63 % of the way in one time constant, all of it in five. |
| 101 | XCTAssertEqual( |
| 102 | glow.value(at: 0.15 + SpeakingGlow.attack), 1 - exp(-1), accuracy: 1e-6) |
| 103 | XCTAssertGreaterThan(glow.value(at: 0.15 + 5 * SpeakingGlow.attack), 0.99) |
| 104 | XCTAssertLessThan(glow.settles, 0.15 + 6 * SpeakingGlow.attack, "and the view stops redrawing") |
| 105 | |
| 106 | // Release: silence at 1 s, and it fades over hundreds of milliseconds, |
| 107 | // not at once and not at the attack's pace. |
| 108 | glow.update(level: 0, speaking: false, now: 1) |
| 109 | XCTAssertFalse(glow.lit) |
| 110 | XCTAssertEqual(glow.value(at: 1), 1, accuracy: 1e-3) |
| 111 | XCTAssertEqual(glow.value(at: 1 + SpeakingGlow.release), exp(-1), accuracy: 1e-3) |
| 112 | XCTAssertGreaterThan(glow.value(at: 1 + SpeakingGlow.attack), 0.8, "falls slower than it rises") |
| 113 | XCTAssertLessThan(glow.value(at: 1 + 5 * SpeakingGlow.release), 0.01) |
| 114 | XCTAssertGreaterThan(glow.settles, 1 + 4 * SpeakingGlow.release) |
| 115 | XCTAssertLessThan(glow.settles, 1 + 7 * SpeakingGlow.release) |
| 116 | XCTAssertTrue((0.25...0.4).contains(SpeakingGlow.release)) |
| 117 | XCTAssertEqual(SpeakingGlow.attack, 0.05) |
| 118 | } |
| 119 | |
| 120 | func testAReadingMidwayCarriesOnFromWhereItWas() { |
| 121 | var glow = SpeakingGlow() |
| 122 | glow.update(level: level(db: -12), speaking: true, now: 0) |
| 123 | glow.update(level: level(db: -12), speaking: true, now: 0.2) |
| 124 | let midway = glow.value(at: 0.225) |
| 125 | XCTAssertTrue(glow.update(level: level(db: -40), speaking: true, now: 0.225)) |
| 126 | XCTAssertEqual(glow.value(at: 0.225), midway, accuracy: 1e-9, "no jump when the target moves") |
| 127 | } |
| 128 | |
| 129 | func testAReadingThatChangesNothingVisibleIsNotAChange() { |
| 130 | var glow = SpeakingGlow() |
| 131 | XCTAssertFalse(glow.update(level: 0, speaking: false, now: 0), "silence stays silence") |
| 132 | glow.update(level: level(db: -20), speaking: true, now: 1) |
| 133 | XCTAssertTrue(glow.update(level: level(db: -20), speaking: true, now: 1.2), "lights") |
| 134 | XCTAssertFalse( |
| 135 | glow.update(level: level(db: -20.1), speaking: true, now: 1.3), "a tenth of a dB is not a redraw") |
| 136 | } |
| 137 | |
| 138 | func testReduceMotionIsOneSteadyGlow() { |
| 139 | var glow = SpeakingGlow() |
| 140 | glow.update(level: level(db: -40), speaking: true, now: 0) |
| 141 | XCTAssertEqual(glow.value(at: 0, reduceMotion: true), 0, "not lit yet: nothing") |
| 142 | glow.update(level: level(db: -10), speaking: true, now: 0) |
| 143 | glow.update(level: level(db: -10), speaking: true, now: 0.2) |
| 144 | for (db, t) in [(-10, 0.3), (-35, 0.4), (-20, 0.5)] as [(Float, Double)] { |
| 145 | glow.update(level: level(db: db), speaking: true, now: t) |
| 146 | for dt in [0, 0.01, 0.1, 1.0] { |
| 147 | XCTAssertEqual(glow.value(at: t + dt, reduceMotion: true), SpeakingGlow.steady) |
| 148 | } |
| 149 | } |
| 150 | glow.update(level: 0, speaking: false, now: 1) |
| 151 | XCTAssertEqual(glow.value(at: 1, reduceMotion: true), 0, "out at once, no fade") |
| 152 | } |
| 153 | |
| 154 | func testTheFixtureHoldsALevel() { |
| 155 | XCTAssertFalse(SpeakingGlow.fixture(level: level(db: -60)).lit) |
| 156 | let mid = SpeakingGlow.fixture(level: level(db: -20)) |
| 157 | XCTAssertTrue(mid.lit) |
| 158 | XCTAssertEqual(mid.value(at: 0), mid.value(at: 100), "still") |
| 159 | } |
| 160 | |
| 161 | @MainActor func testTheTilesAndTheTabGlow() { |
| 162 | let levels = AudioLevels() |
| 163 | let unmuted = PeerMedia(muted: false, cameraOff: false) |
| 164 | func tick(_ t: Double, bea: Float, cy: Float) { |
| 165 | levels.updatePeer( |
| 166 | "bea", name: "Bea", |
| 167 | sample: PeerAudioSample(reported: unmuted, connected: true, arriving: true, level: level(db: bea)), |
| 168 | notices: [], now: t) |
| 169 | levels.updatePeer( |
| 170 | "cy", name: "Cy", |
| 171 | sample: PeerAudioSample(reported: unmuted, connected: true, arriving: true, level: level(db: cy)), |
| 172 | notices: [], now: t) |
| 173 | levels.settle(present: ["bea", "cy"]) |
| 174 | } |
| 175 | for t in stride(from: 0.0, through: 1, by: 0.2) { tick(t, bea: -15, cy: -60) } |
| 176 | XCTAssertTrue(levels.peer("bea").glow.glow.lit) |
| 177 | XCTAssertFalse(levels.peer("cy").glow.glow.lit) |
| 178 | XCTAssertEqual(levels.othersGlow.glow, levels.peer("bea").glow.glow, "the tab follows Bea") |
| 179 | levels.settle(present: ["cy"]) |
| 180 | XCTAssertFalse(levels.peer("bea").glow.glow.lit, "someone who left does not glow") |
| 181 | XCTAssertFalse(levels.othersGlow.glow.lit) |
| 182 | |
| 183 | // Your own tile, and muting puts it out. |
| 184 | let mic = { (muted: Bool, db: Float) in |
| 185 | MicSample(muted: muted, capturing: true, inputPeak: self.level(db: db), inputBuffers: 10, sentPeak: self.level(db: db), sending: true) |
| 186 | } |
| 187 | for t in stride(from: 0.0, through: 0.5, by: 0.1) { |
| 188 | levels.updateMic(mic(false, -15), now: t, source: "", channels: false) |
| 189 | } |
| 190 | XCTAssertTrue(levels.mic.glow.glow.lit) |
| 191 | levels.updateMic(mic(true, -15), now: 0.6, source: "", channels: false) |
| 192 | XCTAssertFalse(levels.mic.glow.glow.lit) |
| 193 | } |
| 194 | } |
| 195 | |