oficina / meet Public

Speaking glow behind the name replaces the ring and the tile meter (Mac, iPhone)

SpeakingGlow is the pure rule: a reading's brightness by dB between
-42 and -12 dBFS over a 0.3 floor while lit, an exponential approach
with a 50 ms attack and 300 ms release, lit only once speech-level sound
has kept up for 0.15 s (a click never lights it), and one steady 0.6
under Reduce Motion. GlowLevel publishes only on a visible change; the
halo redraws on a timeline that stops once the glow has arrived.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 081abad7eb54cbb5fb4c787dd04c810457a012aa parent 57d98cd
8 files changed, +498 −62
  • native/apple/Sources/CallsKit/AudioMeter.swift +153 −43
  • native/apple/Sources/CallsKit/CallsAppearance.swift +3 −0
  • native/apple/Sources/CallsKit/DevicePanelAudio.swift +2 −2
  • native/apple/Sources/CallsKit/MacVideoTile.swift +11 −8
  • native/apple/Sources/CallsKit/SpeakingGlow.swift +122 −0
  • native/apple/Tests/CallsKitTests/AudioLevelTests.swift +1 −1
  • native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift +195 −0
  • native/ios/Sources/CallsPhone.swift +11 −8
diff --git a/native/apple/Sources/CallsKit/AudioMeter.swift b/native/apple/Sources/CallsKit/AudioMeter.swift
index c8e48d6..7f7840f 100644
--- a/native/apple/Sources/CallsKit/AudioMeter.swift
+++ b/native/apple/Sources/CallsKit/AudioMeter.swift
@@ -1,14 +1,17 @@
import Combine
import SwiftUI
-// Spec §15.20 on the tiles: a meter for every voice, measured where it can
-// be — what you send on your own tile, what arrives on everyone else's —
-// and a sentence in place of the meter when the sound has gone flat.
+// Spec §15.20 on the tiles: every voice measured where it can be — what
+// you send on your own tile, what arrives on everyone else's — shown as
+// the speaking glow behind the name (0.3.7; see SpeakingGlow), and a
+// sentence in its place when the sound has gone flat.
//
// The levels live in small observable objects of their own, one per tile,
-// rather than on `Call`: a meter that changes a few times a second must not
-// re-render the whole call view each time. Each object publishes only when
-// what it shows changes — a meter step, a line appearing — not per reading.
+// rather than on `Call`: a level that changes a few times a second must
+// not re-render the whole call view each time. Each object publishes only
+// when what it shows changes — a step, a line appearing — not per
+// reading. The glow has an object of its own (GlowLevel), so only the
+// glow redraws as it breathes.
/// Your own microphone, for your tile.
@MainActor public final class MicAudio: ObservableObject {
@@ -22,6 +25,8 @@ import SwiftUI
/// The microphone has more than one input, so the ⓘ points at the
/// Input picker.
@Published public private(set) var channels = false
+ /// The glow behind your name.
+ public let glow = GlowLevel()
public init() {}
@@ -41,6 +46,8 @@ import SwiftUI
@Published public private(set) var speaking = false
/// Their report about you: blocked, or not hearing you.
@Published public private(set) var notices: [String] = []
+ /// The glow behind their name.
+ public let glow = GlowLevel()
public init() {}
@@ -51,6 +58,34 @@ import SwiftUI
}
}
+/// One name's speaking glow, observed alone: only the glow redraws as it
+/// follows a voice. Publishes when the glow's target or lit state moves by
+/// something visible (SpeakingGlow.update says when), a few times a
+/// second at most while someone speaks and not at all in silence; between
+/// those the view works the brightness out for itself.
+@MainActor public final class GlowLevel: ObservableObject {
+ @Published public private(set) var glow = SpeakingGlow()
+ private var rule = SpeakingGlow()
+
+ public init() {}
+
+ func update(level: Float, speaking: Bool, now: Double) {
+ if rule.update(level: level, speaking: speaking, now: now) { glow = rule }
+ }
+
+ /// Takes another glow's state whole: the mini window's tab showing the
+ /// loudest person speaking.
+ func follow(_ other: SpeakingGlow) {
+ rule = other
+ if glow != other { glow = other }
+ }
+
+ func reset() { follow(SpeakingGlow()) }
+
+ /// Holds the glow at a level, for the snapshots.
+ @_spi(Fixtures) public func showFixture(level: Float) { follow(.fixture(level: level)) }
+}
+
/// Every meter in a call, fed by `Call` (see `Call.watchLevels`).
@MainActor public final class AudioLevels {
public let mic = MicAudio()
@@ -59,6 +94,8 @@ import SwiftUI
private var micWatch = MicWatch()
/// Anyone other than you speaking, for the mini window's tab.
public let anyoneSpeaking = SpeakingNow()
+ /// The glow of the loudest of them, for the same tab.
+ public let othersGlow = GlowLevel()
public init() {}
@@ -77,6 +114,7 @@ import SwiftUI
mic.show(
step: micWatch.step, muted: sample.muted, speaking: micWatch.speaking,
silent: micWatch.silent, source: source, channels: channels)
+ mic.glow.update(level: sample.sentPeak, speaking: micWatch.speaking, now: now)
if micWatch.silent, !wasSilent { AudioAnnouncer.say(AudioWords.micSilent) }
}
@@ -92,19 +130,24 @@ import SwiftUI
let model = peer(id)
let fresh = notices.filter { !model.notices.contains($0) }
model.show(state: state, speaking: watch.speaking, notices: notices)
+ model.glow.update(level: sample.level, speaking: watch.speaking, now: now)
if state == .noSound, before != .noSound { AudioAnnouncer.say(AudioWords.noSound(from: name)) }
for line in fresh { AudioAnnouncer.say(line) }
return state
}
/// Forgets everyone not in `present`, and says whether anyone left is
- /// speaking.
+ /// speaking, and how brightly the loudest of them glows.
func settle(present: Set<String>) {
for id in Array(watches.keys) where !present.contains(id) {
watches.removeValue(forKey: id)
peers[id]?.show(state: .meter(0), speaking: false, notices: [])
+ peers[id]?.glow.reset()
}
anyoneSpeaking.set(watches.values.contains { $0.speaking })
+ let loudest = watches.keys.compactMap { peers[$0]?.glow.glow }
+ .filter(\.lit).max { $0.target < $1.target }
+ othersGlow.follow(loudest ?? SpeakingGlow())
}
/// The people whose sound has gone flat, in a stable order: what this
@@ -116,9 +159,14 @@ import SwiftUI
func reset() {
micWatch = MicWatch()
mic.show(step: 0, muted: false, speaking: false, silent: false, source: "", channels: false)
- for id in Array(watches.keys) { peers[id]?.show(state: .meter(0), speaking: false, notices: []) }
+ mic.glow.reset()
+ for id in Array(watches.keys) {
+ peers[id]?.show(state: .meter(0), speaking: false, notices: [])
+ peers[id]?.glow.reset()
+ }
watches = [:]
anyoneSpeaking.set(false)
+ othersGlow.reset()
}
}
@@ -141,13 +189,24 @@ import SwiftUI
public enum TileAudio {
case you(MicAudio)
case peer(PeerAudio, name: String)
+
+ /// The glow behind the tile's name.
+ @MainActor public var glow: GlowLevel {
+ switch self {
+ case .you(let mic): mic.glow
+ case .peer(let peer, _): peer.glow
+ }
+ }
}
/// A tile's sound, read out of its model as plain values.
public struct TileAudioInfo: Equatable {
public enum Status: Equatable {
+ /// Muted: the name label says so, and there is nothing to glow.
case none
- case meter(Int)
+ /// Sound is live: the glow behind the name carries it.
+ case live
+ /// "No sound from Bea", in place of the glow.
case noSound(String)
}
public var status: Status
@@ -168,7 +227,7 @@ public struct TileAudioInfo: Equatable {
@MainActor static func you(_ mic: MicAudio) -> TileAudioInfo {
TileAudioInfo(
- status: mic.muted ? .none : .meter(mic.step), speaking: mic.speaking,
+ status: mic.muted ? .none : .live, speaking: mic.speaking,
notices: mic.silent ? [AudioWords.micSilent] : [],
detail: mic.silent
? AudioWords.micSilentDetail(source: mic.source, channels: mic.channels) : nil)
@@ -179,7 +238,7 @@ public struct TileAudioInfo: Equatable {
switch audio.state {
case .muted: status = .none
case .noSound: status = .noSound(name)
- case .meter(let step): status = .meter(step)
+ case .meter: status = .live
}
return TileAudioInfo(
status: status, speaking: audio.speaking, notices: audio.notices,
@@ -217,23 +276,29 @@ public struct TileAudioReader<Content: View>: View {
}
}
-/// A small level meter: bars that light from the left. Steps, not a
-/// continuous level, so it never animates frame by frame, and with Reduce
-/// Motion it does not animate at all. Hidden from assistive technology:
-/// the words beside it carry anything worth saying.
+/// A small level meter: bars that light from the left, in the Calls teal.
+/// Steps, not a continuous level, so it never animates frame by frame,
+/// and with Reduce Motion it does not animate at all. The device panel's
+/// "Microphone level" row, for setting up; the tiles have the glow.
+/// Hidden from assistive technology: the words beside it carry anything
+/// worth saying.
public struct AudioLevelBars: View {
let step: Int
var height: CGFloat = 12
@Environment(\.accessibilityReduceMotion) private var reduceMotion
+ @Environment(\.colorScheme) private var scheme
public init(step: Int, height: CGFloat = 12) {
self.step = step
self.height = height
}
public var body: some View {
+ // The glow's teal on a dark panel; the deeper accent on a light sheet,
+ // where the lit one would be too pale to tell from the unlit.
+ let lit = scheme == .dark ? CallsAppearance.glow : CallsAppearance.accent
HStack(alignment: .bottom, spacing: 2) {
ForEach(0..<MeterScale.steps, id: \.self) { bar in
- RoundedRectangle(cornerRadius: 1)
- .fill(bar < step ? Color.white : CallOverlay.secondaryText.opacity(0.4))
+ Capsule()
+ .fill(bar < step ? lit : Color.secondary.opacity(0.35))
.frame(width: 3, height: height * CGFloat(bar + 2) / CGFloat(MeterScale.steps + 1))
}
}
@@ -243,23 +308,17 @@ public struct AudioLevelBars: View {
}
}
-/// The sound beside a tile's name: the meter, or "No sound from Bea" with
-/// its ⓘ. Nothing while muted — the name label already says Muted.
+/// What a tile says beside its name about its sound: "No sound from Bea"
+/// with its ⓘ, or nothing — live sound is the glow behind the name, and
+/// muted is the name label's own mark.
public struct TileAudioStatus: View {
let info: TileAudioInfo?
public init(_ info: TileAudioInfo?) { self.info = info }
public var body: some View {
- switch info?.status {
- case .meter(let step)?:
- AudioLevelBars(step: step)
- .padding(.horizontal, 7).padding(.vertical, 5)
- .callOverlaySurface(Capsule())
- case .noSound(let name)?:
+ if case .noSound(let name)? = info?.status {
AudioLine(
text: AudioWords.noSound(from: name), systemImage: "speaker.slash.fill",
detail: info?.detail)
- default:
- EmptyView()
}
}
}
@@ -320,25 +379,69 @@ struct AudioLine: View {
}
extension View {
- /// The speaking highlight round a tile: a white ring, on and off in
- /// steps (see SpeakingGate), and without a fade under Reduce Motion.
- public func speakingRing(_ on: Bool, cornerRadius: CGFloat) -> some View {
- modifier(SpeakingRing(on: on, cornerRadius: cornerRadius))
+ /// The speaking glow behind a name label (0.3.7): the Calls teal as a
+ /// soft halo round `shape`, lit while that person speaks and as bright
+ /// as their voice, from `glow` (nil draws nothing: a screen's label).
+ /// Behind the label and outside its layout, so nothing moves when it
+ /// lights; no border, no flash.
+ public func speakingGlow<S: Shape>(_ glow: GlowLevel?, in shape: S) -> some View {
+ background {
+ if let glow { SpeakingGlowHalo(level: glow, shape: shape) }
+ }
}
}
-private struct SpeakingRing: ViewModifier {
- let on: Bool
- let cornerRadius: CGFloat
+/// The halo itself. It redraws only while the glow is moving: a
+/// timeline that runs from a new reading until the glow has arrived
+/// where that reading sent it (`SpeakingGlow.settles`), at up to 30 Hz,
+/// and stops. In silence it draws nothing and nothing runs. With Reduce
+/// Motion there is no timeline at all: a steady glow while lit.
+struct SpeakingGlowHalo<S: Shape>: View {
+ @ObservedObject var level: GlowLevel
+ let shape: S
@Environment(\.accessibilityReduceMotion) private var reduceMotion
- func body(content: Content) -> some View {
- content.overlay {
- RoundedRectangle(cornerRadius: cornerRadius)
- .strokeBorder(Color.white.opacity(on ? 0.85 : 0), lineWidth: 3)
- .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: on)
- .allowsHitTesting(false)
+
+ var body: some View {
+ let glow = level.glow
+ if reduceMotion {
+ halo(glow.value(at: 0, reduceMotion: true))
+ } else {
+ TimelineView(GlowSchedule(until: Self.date(uptime: glow.settles))) { _ in
+ halo(glow.value(at: ProcessInfo.processInfo.systemUptime))
+ }
}
}
+
+ /// Spread and brightness both follow the value: a faint close halo for
+ /// a quiet voice, a wider brighter one for a loud one.
+ @ViewBuilder private func halo(_ value: Double) -> some View {
+ if value > 0.01 {
+ let v = CGFloat(value)
+ ZStack {
+ shape.fill(CallsAppearance.glow.opacity(0.35 + 0.45 * value))
+ .padding(-(2 + 6 * v)).blur(radius: 4 + 8 * v)
+ shape.stroke(CallsAppearance.glow.opacity(0.5 * value), lineWidth: 2 + 2 * v)
+ .blur(radius: 2)
+ }
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+ }
+
+ private static func date(uptime: Double) -> Date {
+ Date().addingTimeInterval(uptime - ProcessInfo.processInfo.systemUptime)
+ }
+}
+
+/// Every thirtieth of a second from now until `until`, then nothing.
+struct GlowSchedule: TimelineSchedule {
+ let until: Date
+ func entries(from start: Date, mode: TimelineScheduleMode) -> [Date] {
+ guard until > start else { return [start] }
+ let every = mode == .lowFrequency ? 0.25 : 1.0 / 30
+ let count = min(Int(until.timeIntervalSince(start) / every) + 2, 300)
+ return (0..<count).map { start.addingTimeInterval(Double($0) * every) }
+ }
}
/// A tile's accessibility label with its sound said after the name.
@@ -347,14 +450,21 @@ public func tileAccessibilityLabel(_ label: String, muted: Bool, audio: TileAudi
}
/// A waveform while someone is speaking, and nothing otherwise: the mini
-/// window's tab. Hidden from assistive technology, which would hear it
-/// flicker on and off with every sentence.
+/// window's tab, with the same glow behind it as a name has. Hidden from
+/// assistive technology, which would hear it flicker on and off with
+/// every sentence.
public struct SpeakingGlyph: View {
@ObservedObject var speaking: SpeakingNow
- public init(speaking: SpeakingNow) { self.speaking = speaking }
+ let glow: GlowLevel?
+ public init(speaking: SpeakingNow, glow: GlowLevel? = nil) {
+ self.speaking = speaking
+ self.glow = glow
+ }
public var body: some View {
Image(systemName: "waveform")
.font(.caption.bold()).foregroundStyle(.white)
+ .frame(width: 22, height: 22)
+ .speakingGlow(glow, in: Circle())
.opacity(speaking.value ? 1 : 0)
.help("Someone is speaking")
.accessibilityHidden(true)
diff --git a/native/apple/Sources/CallsKit/CallsAppearance.swift b/native/apple/Sources/CallsKit/CallsAppearance.swift
index 887232f..d7f64f6 100644
--- a/native/apple/Sources/CallsKit/CallsAppearance.swift
+++ b/native/apple/Sources/CallsKit/CallsAppearance.swift
@@ -3,5 +3,8 @@ import SwiftUI
/// Calls's first native palette; app-specific until a second app adopts it.
public enum CallsAppearance {
public static let accent = Color(red: 0.03, green: 0.49, blue: 0.54)
+ /// The accent's hue lit up, for the speaking glow: the accent itself is
+ /// a deep teal that would read as a shadow over video, not a light.
+ public static let glow = Color(red: 0.18, green: 0.80, blue: 0.86)
public static let background = Color.primary.opacity(0.035)
}
diff --git a/native/apple/Sources/CallsKit/DevicePanelAudio.swift b/native/apple/Sources/CallsKit/DevicePanelAudio.swift
index 2c06cee..91550af 100644
--- a/native/apple/Sources/CallsKit/DevicePanelAudio.swift
+++ b/native/apple/Sources/CallsKit/DevicePanelAudio.swift
@@ -14,9 +14,9 @@ public struct MicLevelRow: View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text("Microphone level").font(.subheadline)
+ // The tiles' glow is for a call; setting up wants to see the
+ // level itself, so the panel keeps a compact meter, borderless.
AudioLevelBars(step: mic.muted ? 0 : mic.step, height: 14)
- .padding(.horizontal, 6).padding(.vertical, 4)
- .background(Color.black.opacity(0.75), in: Capsule())
Text(words).font(.footnote).foregroundStyle(.secondary)
}
if mic.silent {
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift
index 38f9380..a2d65f6 100644
--- a/native/apple/Sources/CallsKit/MacVideoTile.swift
+++ b/native/apple/Sources/CallsKit/MacVideoTile.swift
@@ -27,7 +27,7 @@
/// does not fill the tile. A tile among others on the full-window
/// stage is a shade lighter than the stage, so it reads as a tile.
var backdrop = Color.black
- /// The sound on this tile (spec §15.20): your microphone's meter, or a
+ /// The sound on this tile (spec §15.20): your microphone's glow, or a
/// remote person's with "No sound from" and their notices. Nil for a
/// screen.
var audio: TileAudio? = nil
@@ -56,20 +56,23 @@
VStack(alignment: .leading, spacing: 6) {
TileAudioNotices(info)
HStack(spacing: 6) {
- if compact {
- TileNameLabel(label: label, muted: muted)
- } else {
- Text(label).font(.callout.bold()).foregroundStyle(.white)
- .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6)
- .background(.black.opacity(0.65), in: Capsule())
+ Group {
+ if compact {
+ TileNameLabel(label: label, muted: muted)
+ } else {
+ Text(label).font(.callout.bold()).foregroundStyle(.white)
+ .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6)
+ .callOverlaySurface(Capsule())
+ }
}
+ // Speaking is the glow behind the name, as bright as the voice.
+ .speakingGlow(audio?.glow, in: Capsule())
TileAudioStatus(info)
}
}
.padding(compact ? 8 : 10).padding(.bottom, compact ? labelLift : 0)
}
.overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(10) } }
- .speakingRing(info?.speaking == true, cornerRadius: cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.accessibilityElement(children: .ignore)
.accessibilityLabel(tileAccessibilityLabel(label, muted: muted, audio: info))
diff --git a/native/apple/Sources/CallsKit/SpeakingGlow.swift b/native/apple/Sources/CallsKit/SpeakingGlow.swift
new file mode 100644
index 0000000..3af8a57
--- /dev/null
+++ b/native/apple/Sources/CallsKit/SpeakingGlow.swift
@@ -0,0 +1,122 @@
+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 soft
+// glow in the Calls teal behind the name label, lit while that person is
+// speaking and breathing with their voice. 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 glow 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
+ /// The least a lit glow shows, so it dims between syllables but does
+ /// not go out while someone is still talking.
+ public static let floor: Double = 0.3
+ /// 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
+
+ /// 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` — before the floor a lit glow never goes under.
+ 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.floor + (1 - Self.floor) * 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)
+ }
+
+ /// 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 ? floor + (1 - floor) * intensity(level: level) : 0
+ glow.from = glow.target
+ return glow
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/AudioLevelTests.swift b/native/apple/Tests/CallsKitTests/AudioLevelTests.swift
index 61ff778..ed82243 100644
--- a/native/apple/Tests/CallsKitTests/AudioLevelTests.swift
+++ b/native/apple/Tests/CallsKitTests/AudioLevelTests.swift
@@ -210,7 +210,7 @@ final class AudioLevelTests: XCTestCase {
peer.show(state: .muted, speaking: false, notices: [])
let muted = TileAudioInfo.peer(peer, name: "Bea")
- XCTAssertEqual(muted.status, .none, "muted: the badge, and no meter")
+ XCTAssertEqual(muted.status, .none, "muted: the badge, and no glow")
XCTAssertEqual(tileAccessibilityLabel("Bea", muted: true, audio: muted), "Bea, muted")
let you = MicAudio()
diff --git a/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift
new file mode 100644
index 0000000..1034510
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift
@@ -0,0 +1,195 @@
+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.floor + (1 - SpeakingGlow.floor) * SpeakingGlow.intensity(level: level(db: -20)),
+ accuracy: 1e-9)
+ // Between syllables it dims but stays lit, as the gate does.
+ voice.hear(db: -45, at: 0.3)
+ XCTAssertTrue(voice.glow.lit)
+ XCTAssertEqual(voice.glow.target, SpeakingGlow.floor, accuracy: 1e-9)
+ // 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))
+ }
+ 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)
+ }
+}
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index d6b4391..9b1f839 100644
--- a/native/ios/Sources/CallsPhone.swift
+++ b/native/ios/Sources/CallsPhone.swift
@@ -396,7 +396,7 @@ struct PhoneVideoTile: View {
/// Behind a picture that is not there: a shade lighter than the stage
/// for a tile among others, so it reads as a tile.
var backdrop = Color.black
- /// The sound on this tile (spec §15.20): your microphone's meter, or a
+ /// The sound on this tile (spec §15.20): your microphone's glow, or a
/// remote person's with "No sound from" and their notices. Nil for a
/// screen.
var audio: TileAudio? = nil
@@ -425,20 +425,23 @@ struct PhoneVideoTile: View {
VStack(alignment: .leading, spacing: 4) {
TileAudioNotices(info)
HStack(spacing: 5) {
- if compact {
- TileNameLabel(label: label, muted: muted)
- } else {
- Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1)
- .padding(.horizontal, 10).padding(.vertical, 6)
- .background(.black.opacity(0.65), in: Capsule())
+ Group {
+ if compact {
+ TileNameLabel(label: label, muted: muted)
+ } else {
+ Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1)
+ .padding(.horizontal, 10).padding(.vertical, 6)
+ .callOverlaySurface(Capsule())
+ }
}
+ // Speaking is the glow behind the name, as bright as the voice.
+ .speakingGlow(audio?.glow, in: Capsule())
TileAudioStatus(info)
}
}
.padding(compact ? 6 : 8).padding(compact ? labelInsets : EdgeInsets())
}
.overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } }
- .speakingRing(info?.speaking == true, cornerRadius: cornerRadius)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.accessibilityElement(children: .ignore)
.accessibilityLabel(tileAccessibilityLabel(label, muted: muted, audio: info))