import Foundation // 0.3.7: the speaking glow. The white ring round a tile and the meter // beside a name were two marks for one thing; they are now one — a light // on the name label, lit while that person is speaking and breathing with // their voice. Since 0.4.3 that light is the warm rim (WarmRim: "T5, warm // soft breath"); until then it was a teal halo behind the label. This is // the rule behind it, on the caller's clock, with nothing about views or // the main actor in it: what a reading makes the light aim for, and how // bright it is at any moment on the way there. // // The levels arrive a few times a second (Call.watchLevels: 10 Hz for // your microphone, 5 Hz for everyone else). Between readings the glow is // an exponential approach from where it was to where it is going — quick // rising (`attack`), slower falling (`release`) — so a view can ask for // the value at any instant without being told anything new, and a // reading that changes nothing visible changes nothing at all. /// The glow behind one name: whether it is lit, where its brightness is /// heading, and how bright it is at a given time. public struct SpeakingGlow: Equatable, Sendable { /// The level at which a lit glow is at its dimmest, and the level at /// which it is brightest. Speech peaks sit around -20 to -6 dBFS; the /// bottom is where the speaking highlight lets go (SpeakingGate.offDb). public static let quietDb: Float = SpeakingGate.offDb public static let loudDb: Float = -12 /// Time constants, in seconds: how quickly the glow rises to a louder /// reading and falls to a quieter one (63 % of the way in that time). public static let attack: Double = 0.05 public static let release: Double = 0.3 /// How long sound at a speaking level must keep up before the glow /// lights: the gap between the first and the latest loud reading in one /// stretch of speaking. A click, a cough or a door is one reading and /// never lights it; a word is several. public static let onset: Double = 0.15 /// With Reduce Motion: one brightness while lit, and no movement. public static let steady: Double = 0.6 /// A change of target smaller than this is not worth a redraw. public static let resolution: Double = 0.02 /// Closer than this to its target, the glow has arrived. static let arrived: Double = 0.005 /// A glow that has stopped being lit stays on — its rim and inner light /// still showing — until its level has fallen under this, so it fades /// with the release rather than going out at once. public static let outBelow: Double = 0.05 /// Lit: speaking, per SpeakingGate, and past the onset. public private(set) var lit = false /// Where the brightness is heading, 0...1. public private(set) var target: Double = 0 /// The brightness at `at`, where the approach to `target` began. private var from: Double = 0 private var at: Double = 0 private var loudFrom: Double? private var loudLast: Double? public init() {} /// A reading's brightness, 0...1, by decibels between `quietDb` and /// `loudDb`: the level the warm rim is drawn at. public static func intensity(level: Float) -> Double { let db = MeterScale.decibels(level) guard db.isFinite else { return 0 } let x = Double((db - quietDb) / (loudDb - quietDb)) return min(1, max(0, x)) } /// Takes one reading: the level, whether the person is speaking per /// the speaking gate, and when. Returns whether what is drawn changes, /// so the caller publishes only then. @discardableResult public mutating func update(level: Float, speaking: Bool, now: Double) -> Bool { if !speaking { loudFrom = nil loudLast = nil } else if MeterScale.decibels(level) >= SpeakingGate.onDb { if loudFrom == nil { loudFrom = now } loudLast = now } var nowLit = false if speaking { if lit { nowLit = true } else if let first = loudFrom, let last = loudLast { nowLit = last - first >= Self.onset } } let next = nowLit ? Self.intensity(level: level) : 0 if nowLit == lit, abs(next - target) < Self.resolution { return false } from = value(at: now) at = now target = next lit = nowLit return true } /// The brightness at `now`, 0...1. With Reduce Motion, `steady` while /// lit and nothing otherwise: no approach, so nothing moves. public func value(at now: Double, reduceMotion: Bool = false) -> Double { if reduceMotion { return lit ? Self.steady : 0 } let elapsed = max(0, now - at) let tau = target > from ? Self.attack : Self.release return target + (from - target) * exp(-elapsed / tau) } /// Whether the light is on at `now`: lit, or still fading from being /// lit (see `outBelow`). With Reduce Motion, lit alone. public func on(at now: Double, reduceMotion: Bool = false) -> Bool { if lit { return true } return !reduceMotion && value(at: now) >= Self.outBelow } /// The warm rim as drawn at `now`. public func rim(at now: Double, reduceMotion: Bool = false) -> WarmRim { WarmRim(on: on(at: now, reduceMotion: reduceMotion), level: value(at: now, reduceMotion: reduceMotion)) } /// When the glow will have arrived at its target, on the same clock: /// the moment a view can stop redrawing it. public var settles: Double { let gap = abs(from - target) guard gap > Self.arrived else { return at } let tau = target > from ? Self.attack : Self.release return at + tau * log(gap / Self.arrived) } /// A glow held at `level`'s brightness, for the snapshots. @_spi(Fixtures) public static func fixture(level: Float) -> SpeakingGlow { var glow = SpeakingGlow() let on = MeterScale.decibels(level) >= SpeakingGate.onDb glow.lit = on glow.target = on ? intensity(level: level) : 0 glow.from = glow.target return glow } }