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>
8 files changed,
+498
−62
native/apple/Sources/CallsKit/AudioMeter.swift+153 −43native/apple/Sources/CallsKit/CallsAppearance.swift+3 −0native/apple/Sources/CallsKit/DevicePanelAudio.swift+2 −2native/apple/Sources/CallsKit/MacVideoTile.swift+11 −8native/apple/Sources/CallsKit/SpeakingGlow.swift+122 −0native/apple/Tests/CallsKitTests/AudioLevelTests.swift+1 −1native/apple/Tests/CallsKitTests/SpeakingGlowTests.swift+195 −0native/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)) |