import XCTest @_spi(Fixtures) @testable import CallsKit /// The speaking glow's rule (0.3.7) on a clock the tests own: what lights /// it and what does not, how quickly it rises and falls, silence, and /// Reduce Motion. final class SpeakingGlowTests: XCTestCase { private func level(db: Float) -> Float { pow(10, db / 20) } /// Feeds a gate and a glow the same readings, as AudioLevels does. private struct Voice { var gate = SpeakingGate() var glow = SpeakingGlow() mutating func hear(db: Float, at now: Double) { let level = db.isFinite ? pow(10, db / 20) : 0 let speaking = gate.update(level: level, now: now) glow.update(level: level, speaking: speaking, now: now) } } func testBrightnessFollowsDecibels() { XCTAssertEqual(SpeakingGlow.intensity(level: 0), 0) XCTAssertEqual(SpeakingGlow.intensity(level: level(db: -60)), 0) XCTAssertEqual(SpeakingGlow.intensity(level: level(db: SpeakingGlow.quietDb)), 0, accuracy: 1e-6) XCTAssertEqual(SpeakingGlow.intensity(level: level(db: -27)), 0.5, accuracy: 1e-3) XCTAssertEqual(SpeakingGlow.intensity(level: level(db: SpeakingGlow.loudDb)), 1, accuracy: 1e-6) XCTAssertEqual(SpeakingGlow.intensity(level: 1), 1) var last = 0.0 for db in stride(from: Float(-90), through: 0, by: 1) { let x = SpeakingGlow.intensity(level: level(db: db)) XCTAssertGreaterThanOrEqual(x, last, "louder is never dimmer") last = x } } func testSilenceIsNoGlow() { var voice = Voice() for t in stride(from: 0.0, through: 5, by: 0.1) { voice.hear(db: -.infinity, at: t) XCTAssertFalse(voice.glow.lit) XCTAssertEqual(voice.glow.value(at: t), 0) XCTAssertEqual(voice.glow.value(at: t, reduceMotion: true), 0) } // A quiet room's tone, under the speaking level, is not speech either. for t in stride(from: 5.0, through: 10, by: 0.1) { voice.hear(db: -55, at: t) } XCTAssertFalse(voice.glow.lit) } func testABriefNoiseDoesNotLightIt() { // One loud reading — a click, a cough, a door — then quiet. The gate // calls that speaking for its hangover; the glow waits for more. var voice = Voice() voice.hear(db: -8, at: 0) XCTAssertTrue(voice.gate.speaking, "the gate is on at once") for t in stride(from: 0.1, through: 2, by: 0.1) { voice.hear(db: -70, at: t) XCTAssertFalse(voice.glow.lit, "at \(t)s") XCTAssertEqual(voice.glow.value(at: t), 0) } // Two clicks a second and a half apart are two brief noises, not a // voice, read at the peers' five times a second. voice.hear(db: -8, at: 3) for t in stride(from: 3.2, through: 4.2, by: 0.2) { voice.hear(db: -70, at: t) } voice.hear(db: -8, at: 4.4) for t in stride(from: 4.6, through: 5.4, by: 0.2) { voice.hear(db: -70, at: t) XCTAssertFalse(voice.glow.lit, "at \(t)s") } } func testSpeechLightsItOnceItKeepsUp() { var voice = Voice() voice.hear(db: -20, at: 0) XCTAssertFalse(voice.glow.lit, "one reading is not yet a voice") voice.hear(db: -20, at: 0.1) XCTAssertFalse(voice.glow.lit) voice.hear(db: -20, at: 0.2) XCTAssertTrue(voice.glow.lit, "sound at a speaking level for \(SpeakingGlow.onset)s is") XCTAssertEqual(voice.glow.target, SpeakingGlow.intensity(level: level(db: -20)), accuracy: 1e-9) // Between syllables its level falls away but it stays lit, as the // gate does: the rim and inner light hold at their least. voice.hear(db: -45, at: 0.3) XCTAssertTrue(voice.glow.lit) XCTAssertEqual(voice.glow.target, 0, accuracy: 1e-9) XCTAssertTrue(voice.glow.on(at: 5)) // And goes out when the gate lets go. for t in stride(from: 0.4, through: 1.2, by: 0.1) { voice.hear(db: -70, at: t) } XCTAssertFalse(voice.glow.lit) XCTAssertEqual(voice.glow.target, 0) } func testItRisesQuicklyAndFallsSlowly() { var glow = SpeakingGlow() let loud = level(db: -12) glow.update(level: loud, speaking: true, now: 0) glow.update(level: loud, speaking: true, now: 0.15) XCTAssertTrue(glow.lit) XCTAssertEqual(glow.value(at: 0.15), 0, accuracy: 1e-9, "from where it was") // Attack: 63 % of the way in one time constant, all of it in five. XCTAssertEqual( glow.value(at: 0.15 + SpeakingGlow.attack), 1 - exp(-1), accuracy: 1e-6) XCTAssertGreaterThan(glow.value(at: 0.15 + 5 * SpeakingGlow.attack), 0.99) XCTAssertLessThan(glow.settles, 0.15 + 6 * SpeakingGlow.attack, "and the view stops redrawing") // Release: silence at 1 s, and it fades over hundreds of milliseconds, // not at once and not at the attack's pace. glow.update(level: 0, speaking: false, now: 1) XCTAssertFalse(glow.lit) XCTAssertEqual(glow.value(at: 1), 1, accuracy: 1e-3) XCTAssertEqual(glow.value(at: 1 + SpeakingGlow.release), exp(-1), accuracy: 1e-3) XCTAssertGreaterThan(glow.value(at: 1 + SpeakingGlow.attack), 0.8, "falls slower than it rises") XCTAssertLessThan(glow.value(at: 1 + 5 * SpeakingGlow.release), 0.01) XCTAssertGreaterThan(glow.settles, 1 + 4 * SpeakingGlow.release) XCTAssertLessThan(glow.settles, 1 + 7 * SpeakingGlow.release) XCTAssertTrue((0.25...0.4).contains(SpeakingGlow.release)) XCTAssertEqual(SpeakingGlow.attack, 0.05) } func testAReadingMidwayCarriesOnFromWhereItWas() { var glow = SpeakingGlow() glow.update(level: level(db: -12), speaking: true, now: 0) glow.update(level: level(db: -12), speaking: true, now: 0.2) let midway = glow.value(at: 0.225) XCTAssertTrue(glow.update(level: level(db: -40), speaking: true, now: 0.225)) XCTAssertEqual(glow.value(at: 0.225), midway, accuracy: 1e-9, "no jump when the target moves") } func testAReadingThatChangesNothingVisibleIsNotAChange() { var glow = SpeakingGlow() XCTAssertFalse(glow.update(level: 0, speaking: false, now: 0), "silence stays silence") glow.update(level: level(db: -20), speaking: true, now: 1) XCTAssertTrue(glow.update(level: level(db: -20), speaking: true, now: 1.2), "lights") XCTAssertFalse( glow.update(level: level(db: -20.1), speaking: true, now: 1.3), "a tenth of a dB is not a redraw") } func testReduceMotionIsOneSteadyGlow() { var glow = SpeakingGlow() glow.update(level: level(db: -40), speaking: true, now: 0) XCTAssertEqual(glow.value(at: 0, reduceMotion: true), 0, "not lit yet: nothing") glow.update(level: level(db: -10), speaking: true, now: 0) glow.update(level: level(db: -10), speaking: true, now: 0.2) for (db, t) in [(-10, 0.3), (-35, 0.4), (-20, 0.5)] as [(Float, Double)] { glow.update(level: level(db: db), speaking: true, now: t) for dt in [0, 0.01, 0.1, 1.0] { XCTAssertEqual(glow.value(at: t + dt, reduceMotion: true), SpeakingGlow.steady) } } glow.update(level: 0, speaking: false, now: 1) XCTAssertEqual(glow.value(at: 1, reduceMotion: true), 0, "out at once, no fade") } func testTheFixtureHoldsALevel() { XCTAssertFalse(SpeakingGlow.fixture(level: level(db: -60)).lit) let mid = SpeakingGlow.fixture(level: level(db: -20)) XCTAssertTrue(mid.lit) XCTAssertEqual(mid.value(at: 0), mid.value(at: 100), "still") } @MainActor func testTheTilesAndTheTabGlow() { let levels = AudioLevels() let unmuted = PeerMedia(muted: false, cameraOff: false) func tick(_ t: Double, bea: Float, cy: Float) { levels.updatePeer( "bea", name: "Bea", sample: PeerAudioSample(reported: unmuted, connected: true, arriving: true, level: level(db: bea)), notices: [], now: t) levels.updatePeer( "cy", name: "Cy", sample: PeerAudioSample(reported: unmuted, connected: true, arriving: true, level: level(db: cy)), notices: [], now: t) levels.settle(present: ["bea", "cy"]) } for t in stride(from: 0.0, through: 1, by: 0.2) { tick(t, bea: -15, cy: -60) } XCTAssertTrue(levels.peer("bea").glow.glow.lit) XCTAssertFalse(levels.peer("cy").glow.glow.lit) XCTAssertEqual(levels.othersGlow.glow, levels.peer("bea").glow.glow, "the tab follows Bea") levels.settle(present: ["cy"]) XCTAssertFalse(levels.peer("bea").glow.glow.lit, "someone who left does not glow") XCTAssertFalse(levels.othersGlow.glow.lit) // Your own tile, and muting puts it out. let mic = { (muted: Bool, db: Float) in MicSample(muted: muted, capturing: true, inputPeak: self.level(db: db), inputBuffers: 10, sentPeak: self.level(db: db), sending: true) } for t in stride(from: 0.0, through: 0.5, by: 0.1) { levels.updateMic(mic(false, -15), now: t, source: "", channels: false) } XCTAssertTrue(levels.mic.glow.glow.lit) levels.updateMic(mic(true, -15), now: 0.6, source: "", channels: false) XCTAssertFalse(levels.mic.glow.glow.lit) } }