oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import 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.
21public 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