| 1 | import Foundation |
| 2 | |
| 3 | // 0.3.7: the speaking glow. The white ring round a tile and the meter |
| 4 | // beside a name were two marks for one thing; they are now one — a light |
| 5 | // on the name label, lit while that person is speaking and breathing with |
| 6 | // their voice. Since 0.4.3 that light is the warm rim (WarmRim: "T5, warm |
| 7 | // soft breath"); until then it was a teal halo behind the label. This is |
| 8 | // the rule behind it, on the caller's clock, with nothing about views or |
| 9 | // the main actor in it: what a reading makes the light aim for, and how |
| 10 | // bright it is at any moment on the way there. |
| 11 | // |
| 12 | // The levels arrive a few times a second (Call.watchLevels: 10 Hz for |
| 13 | // your microphone, 5 Hz for everyone else). Between readings the glow is |
| 14 | // an exponential approach from where it was to where it is going — quick |
| 15 | // rising (`attack`), slower falling (`release`) — so a view can ask for |
| 16 | // the value at any instant without being told anything new, and a |
| 17 | // reading that changes nothing visible changes nothing at all. |
| 18 | |
| 19 | /// The glow behind one name: whether it is lit, where its brightness is |
| 20 | /// heading, and how bright it is at a given time. |
| 21 | public struct SpeakingGlow: Equatable, Sendable { |
| 22 | /// The level at which a lit glow is at its dimmest, and the level at |
| 23 | /// which it is brightest. Speech peaks sit around -20 to -6 dBFS; the |
| 24 | /// bottom is where the speaking highlight lets go (SpeakingGate.offDb). |
| 25 | public static let quietDb: Float = SpeakingGate.offDb |
| 26 | public static let loudDb: Float = -12 |
| 27 | /// Time constants, in seconds: how quickly the glow rises to a louder |
| 28 | /// reading and falls to a quieter one (63 % of the way in that time). |
| 29 | public static let attack: Double = 0.05 |
| 30 | public static let release: Double = 0.3 |
| 31 | /// How long sound at a speaking level must keep up before the glow |
| 32 | /// lights: the gap between the first and the latest loud reading in one |
| 33 | /// stretch of speaking. A click, a cough or a door is one reading and |
| 34 | /// never lights it; a word is several. |
| 35 | public static let onset: Double = 0.15 |
| 36 | /// With Reduce Motion: one brightness while lit, and no movement. |
| 37 | public static let steady: Double = 0.6 |
| 38 | /// A change of target smaller than this is not worth a redraw. |
| 39 | public static let resolution: Double = 0.02 |
| 40 | /// Closer than this to its target, the glow has arrived. |
| 41 | static let arrived: Double = 0.005 |
| 42 | /// A glow that has stopped being lit stays on — its rim and inner light |
| 43 | /// still showing — until its level has fallen under this, so it fades |
| 44 | /// with the release rather than going out at once. |
| 45 | public static let outBelow: Double = 0.05 |
| 46 | |
| 47 | /// Lit: speaking, per SpeakingGate, and past the onset. |
| 48 | public private(set) var lit = false |
| 49 | /// Where the brightness is heading, 0...1. |
| 50 | public private(set) var target: Double = 0 |
| 51 | /// The brightness at `at`, where the approach to `target` began. |
| 52 | private var from: Double = 0 |
| 53 | private var at: Double = 0 |
| 54 | private var loudFrom: Double? |
| 55 | private var loudLast: Double? |
| 56 | |
| 57 | public init() {} |
| 58 | |
| 59 | /// A reading's brightness, 0...1, by decibels between `quietDb` and |
| 60 | /// `loudDb`: the level the warm rim is drawn at. |
| 61 | public static func intensity(level: Float) -> Double { |
| 62 | let db = MeterScale.decibels(level) |
| 63 | guard db.isFinite else { return 0 } |
| 64 | let x = Double((db - quietDb) / (loudDb - quietDb)) |
| 65 | return min(1, max(0, x)) |
| 66 | } |
| 67 | |
| 68 | /// Takes one reading: the level, whether the person is speaking per |
| 69 | /// the speaking gate, and when. Returns whether what is drawn changes, |
| 70 | /// so the caller publishes only then. |
| 71 | @discardableResult |
| 72 | public mutating func update(level: Float, speaking: Bool, now: Double) -> Bool { |
| 73 | if !speaking { |
| 74 | loudFrom = nil |
| 75 | loudLast = nil |
| 76 | } else if MeterScale.decibels(level) >= SpeakingGate.onDb { |
| 77 | if loudFrom == nil { loudFrom = now } |
| 78 | loudLast = now |
| 79 | } |
| 80 | var nowLit = false |
| 81 | if speaking { |
| 82 | if lit { |
| 83 | nowLit = true |
| 84 | } else if let first = loudFrom, let last = loudLast { |
| 85 | nowLit = last - first >= Self.onset |
| 86 | } |
| 87 | } |
| 88 | let next = nowLit ? Self.intensity(level: level) : 0 |
| 89 | if nowLit == lit, abs(next - target) < Self.resolution { return false } |
| 90 | from = value(at: now) |
| 91 | at = now |
| 92 | target = next |
| 93 | lit = nowLit |
| 94 | return true |
| 95 | } |
| 96 | |
| 97 | /// The brightness at `now`, 0...1. With Reduce Motion, `steady` while |
| 98 | /// lit and nothing otherwise: no approach, so nothing moves. |
| 99 | public func value(at now: Double, reduceMotion: Bool = false) -> Double { |
| 100 | if reduceMotion { return lit ? Self.steady : 0 } |
| 101 | let elapsed = max(0, now - at) |
| 102 | let tau = target > from ? Self.attack : Self.release |
| 103 | return target + (from - target) * exp(-elapsed / tau) |
| 104 | } |
| 105 | |
| 106 | /// Whether the light is on at `now`: lit, or still fading from being |
| 107 | /// lit (see `outBelow`). With Reduce Motion, lit alone. |
| 108 | public func on(at now: Double, reduceMotion: Bool = false) -> Bool { |
| 109 | if lit { return true } |
| 110 | return !reduceMotion && value(at: now) >= Self.outBelow |
| 111 | } |
| 112 | |
| 113 | /// The warm rim as drawn at `now`. |
| 114 | public func rim(at now: Double, reduceMotion: Bool = false) -> WarmRim { |
| 115 | WarmRim(on: on(at: now, reduceMotion: reduceMotion), level: value(at: now, reduceMotion: reduceMotion)) |
| 116 | } |
| 117 | |
| 118 | /// When the glow will have arrived at its target, on the same clock: |
| 119 | /// the moment a view can stop redrawing it. |
| 120 | public var settles: Double { |
| 121 | let gap = abs(from - target) |
| 122 | guard gap > Self.arrived else { return at } |
| 123 | let tau = target > from ? Self.attack : Self.release |
| 124 | return at + tau * log(gap / Self.arrived) |
| 125 | } |
| 126 | |
| 127 | /// A glow held at `level`'s brightness, for the snapshots. |
| 128 | @_spi(Fixtures) public static func fixture(level: Float) -> SpeakingGlow { |
| 129 | var glow = SpeakingGlow() |
| 130 | let on = MeterScale.decibels(level) >= SpeakingGate.onDb |
| 131 | glow.lit = on |
| 132 | glow.target = on ? intensity(level: level) : 0 |
| 133 | glow.from = glow.target |
| 134 | return glow |
| 135 | } |
| 136 | } |
| 137 | |