
Speaking Ring
From syxUI — written for both platforms, not translated between them.
A call avatar whose ring pushes out with the speaker's voice and leaves a trailing halo, collapsing into a slash when muted.
Avatars · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- avatar
- voice
- call
- ring
- audio
- mute
- animated
The actual source
// Speaking Ring · syxUI · https://syxui.dev/components/speaking-ring
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
/// A call avatar whose ring pushes out with the speaker's voice.
///
/// Two rings, deliberately out of step. The inner ring tracks the level through
/// a 45 ms attack, pushing up to `reach` beyond the face at full voice. The
/// outer ghost is a peak-hold that releases over 420 ms, so every syllable
/// leaves a widening halo behind it and a pause is visible as the halo closing
/// in. If both rings moved together there would be no effect to look at.
///
/// With no audio supplied the level is *synthesised*, not looped: three
/// detuned sines rectified into syllables and gated by a phrase envelope that
/// is silent at both ends. And it is synthesised **analytically** — every value
/// in a frame is a closed-form function of one number, including the release,
/// which is a `max` over decayed samples of that same function rather than a
/// filter over history. A filter would make this component unpinnable, and an
/// unpinnable component has no still and no scrub.
///
/// Muting collapses the inner ring onto the face and only *then* draws the
/// slash. That order is the whole point: the slash reads as a consequence of
/// the collapse rather than a second thing that happened at the same time.
struct SpeakingRing: View {
/// Diameter of the face. Everything that should track the avatar — the
/// gap, the reach, the badge, the slash — is a fraction of this, so this
/// is the same drawing in a 32 pt huddle strip and on a 120 pt call tile.
var size: CGFloat = 72
/// Weight of the inner ring. The ghost is derived from it, at 38%.
var ringWidth: CGFloat = 4
/// How far the inner ring pushes at full voice, as a fraction of `size`.
/// A pt constant here is the classic mistake: 9 pt is a shout at 32 pt and
/// a twitch at 120.
var reach: Double = 0.125
/// The speaking colour. Muting desaturates it rather than replacing it, so
/// any accent works without a second colour to keep in sync.
var accent: Color = Color(red: 0.204, green: 0.78, blue: 0.349)
/// Seconds for one phrase. Also the syllable rate: the phrase stretches
/// with it, while the 45 ms attack and 420 ms release do not.
var period: Double = 3.2
/// Whether the mic starts muted. Tapping the ring toggles it from here.
var muted: Bool = false
/// The face is generated from this string: the hue is a hash of it and the
/// initials are the first letter of its first two words.
var name: String = "Sana Iqbal"
/// What the avatar sits on. The mute badge is punched out of the ring and
/// the face in this colour, so it has to match the surface behind it.
var surface: Color = Color(white: 1)
/// The mute badge's disc. Deliberately not derived from `accent`: a muted
/// mic should not be wearing the speaking colour.
var badgeTint: Color = Color(white: 0.18)
/// Badge diameter as a fraction of `size`. 0.24 holds a legible slash down
/// to about 44 pt.
var badgeScale: Double = 0.24
/// The collapse. Hoisted out of the `.spring(...)` call so it is reachable;
/// a number written inline in that call cannot be read or replaced.
var muteResponse: Double = 0.28
var muteDamping: Double = 0.82
/// Push-to-talk's reach-and-hold. Tighter and better damped than the
/// collapse, because a finger holding the ring open should not wobble.
var talkResponse: Double = 0.22
var talkDamping: Double = 0.9
/// Preview override. When set, the component renders that point of one
/// phrase instead of driving its own clock.
var phase: Double? = nil
/// Preview override for the mute spring: 0 live, 1 collapsed and slashed.
/// Set it alongside `phase` to pin a point of the collapse — which is the
/// only way to *look* at whether the slash really arrives after the ring
/// has moved. Not something production code sets.
var muteProgress: Double? = nil
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// `nil` until the reader taps, so `muted` stays the source of truth for
/// the initial state and can be replaced by the param binder.
@State private var toggled: Bool? = nil
@State private var talking = false
@State private var pressedAt: Date? = nil
/// Bumped on a committed tap so the haptic fires on the toggle and not on
/// every press, cancel included.
@State private var muteTicks = 0
/// Which spring the next update runs on.
///
/// A tap ends a push-to-talk hold *and* toggles mute in the same update, so
/// both animated values change at once. Two `.animation(_:value:)`
/// modifiers would then both fire on the same `animatableData` and the
/// winner is a matter of modifier precedence rather than intent — which
/// quietly made `muteResponse` and `muteDamping` dead knobs, because the
/// collapse always rode the push-to-talk spring. The gesture decides
/// instead, which is where the answer actually lives: it knows whether the
/// press turned out to be a tap.
@State private var collapsing = false
private var isMuted: Bool { toggled ?? muted }
private var mute: Double { muteProgress ?? (isMuted ? 1 : 0) }
private var geometry: SpeakingGeometry {
SpeakingGeometry(size: size, ringWidth: ringWidth, reach: reach, badgeScale: badgeScale)
}
var body: some View {
Group {
if let phase {
frame(at: phase)
} else if reduceMotion {
// Reduce Motion lands on silence with the ring at its base
// radius — the component's rest state, not a frozen midpoint.
// The mute spring is left alive: it is feedback for a control
// the reader pressed, not decoration.
frame(at: SpeakingRingKit.restPhase)
} else {
// One display-linked clock. Every curve below is analytic, so
// there is nothing for SwiftUI to interpolate and nothing a
// pinned `phase` cannot reproduce exactly.
TimelineView(.animation) { timeline in
frame(at: SpeakingRing.position(of: timeline.date, period: period))
}
}
}
// One animation on one token, value-scoped so the per-frame `t` from
// the timeline is never itself animated — only the two states a finger
// causes are.
.animation(currentSpring, value: SpeakingPress(muted: isMuted, holding: talking))
.gesture(press)
.sensoryFeedback(.impact(weight: .light), trigger: muteTicks)
// At touch-down, not on release: push-to-talk has already engaged by
// the time the finger settles, so the confirmation belongs there.
.sensoryFeedback(.impact(flexibility: .soft), trigger: talking) { _, now in now }
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(Text("\(name), microphone"))
// Deliberately the mute state and not the level: a value that changed
// sixty times a second would make the component unusable with a
// screen reader running.
.accessibilityValue(Text(isMuted ? "muted" : "on"))
// The gesture is a `DragGesture`, which a VoiceOver activation does not
// reliably synthesise. Without this the button is reachable and dead.
.accessibilityAction { toggleMute() }
}
private var currentSpring: Animation {
collapsing
? .spring(response: muteResponse, dampingFraction: muteDamping)
: .spring(response: talkResponse, dampingFraction: talkDamping)
}
private func frame(at t: Double) -> some View {
SpeakingFrame(
mute: mute,
talk: talking ? 1 : 0,
t: t,
period: period,
geometry: geometry,
accent: accent,
surface: surface,
badgeTint: badgeTint,
name: name
)
}
/* ---------------------------------------------------------- interaction */
/// One gesture for both jobs, which is what keeps them from fighting.
///
/// Touch-down opens the ring to full reach — that is push-to-talk, and it
/// doubles as the acknowledgement of a tap. What the press *meant* is
/// decided on release: short and still inside the ring is a tap, so mute
/// toggles; anything longer was a hold, and a finger that lifted outside
/// cancelled. Composing a `TapGesture` with a `LongPressGesture` instead
/// makes every tap flash push-to-talk on its way through.
private var press: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard pressedAt == nil else { return }
pressedAt = Date()
collapsing = false
// A muted mic transmits nothing, so it does not open. The tap
// still lands and unmutes on release.
if !isMuted { talking = true }
}
.onEnded { value in
let held = Date().timeIntervalSince(pressedAt ?? Date())
pressedAt = nil
talking = false
let box = geometry.extent * 2
let inside = CGRect(x: 0, y: 0, width: box, height: box).contains(value.location)
guard held < SpeakingRingKit.tapWindow, inside else { return }
// Both values now change in this one update, and `toggleMute`
// has already said which spring carries them.
toggleMute()
}
}
/// Retargeting rather than queueing is what stops a double-tap stuttering:
/// `.animation(_:value:)` restarts the spring from the radius and the
/// velocity it currently has, so the second tap reverses out of wherever
/// the first one got to.
private func toggleMute() {
collapsing = true
toggled = !isMuted
muteTicks += 1
}
private static func position(of date: Date, period: Double) -> Double {
let cycle = max(period, 0.4)
return (date.timeIntervalSinceReferenceDate / cycle).truncatingRemainder(dividingBy: 1)
}
}
/// The two states a finger drives, as one token, so one animation covers an
/// update that changes both.
private struct SpeakingPress: Equatable {
var muted: Bool
var holding: Bool
}
/* -------------------------------------------------------------------- frame */
/// One rendered frame, animatable on the two values a finger drives.
///
/// This has to be `Animatable` rather than a plain function of `mute`. The
/// slash's window is a *curve* of the collapse — `ease(mute, 0.72, 1)` — and
/// writing `.trim(to: ease(mute, 0.72, 1))` plainly would have SwiftUI animate
/// the trim from its old value to its new one on the collapse's own spring:
/// the two would move together and the delay that makes the slash a
/// consequence would quietly evaporate. Driving `animatableData` instead
/// re-evaluates the curve every frame, which is the only way the ordering
/// survives.
private struct SpeakingFrame: View, Animatable {
var mute: Double
var talk: Double
/// Point of the phrase. Not part of `animatableData`: it arrives already
/// sampled, once per display frame, and must not be interpolated.
var t: Double
var period: Double
var geometry: SpeakingGeometry
var accent: Color
var surface: Color
var badgeTint: Color
var name: String
// `View` is @MainActor and `Animatable` is not, so this needs the
// annotation or it is a hard error under Swift 6.
nonisolated var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(mute, talk) }
set {
mute = newValue.first
talk = newValue.second
}
}
var body: some View {
let level = SpeakingLevel(t: t, period: period)
let m = min(max(mute, 0), 1)
let held = min(max(talk, 0), 1) * (1 - m)
// Push-to-talk is a floor under the synthesised level, not a
// replacement for it, so a held ring still ripples if there is voice.
let inner = max(level.inner, held)
let ghost = max(level.ghost, held)
return ZStack {
faceContent
.frame(width: geometry.face * 2, height: geometry.face * 2)
.clipShape(Circle())
// 30% off the face. Enough to read as "not live" next to a
// live tile, not enough to look broken on its own.
.saturation(1 - 0.30 * m)
wash(inner: inner, ghost: ghost, mute: m)
ghostLayer(ghost, mute: m)
innerLayer(inner, mute: m)
badgeLayer(mute: m)
}
.frame(width: geometry.extent * 2, height: geometry.extent * 2)
}
/// The face.
///
/// **This is the one view to replace for a real photo:** swap the body for
/// `Image("sana").resizable().scaledToFill()` and the rings, the punch and
/// the badge all still line up, because none of them measure it. The
/// desaturation on mute keeps working too — it is a filter on this group,
/// not a recoloured gradient.
private var faceContent: some View {
let hue = speakingRingHue(name) / 360
return ZStack {
LinearGradient(
colors: [
Color(hue: hue, saturation: 0.34, brightness: 0.88),
Color(hue: hue, saturation: 0.52, brightness: 0.60),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Text(speakingRingInitials(name))
.font(.system(size: geometry.face * 0.708, weight: .semibold, design: .rounded))
.foregroundStyle(Color(hue: hue, saturation: 0.66, brightness: 0.30))
}
}
/// The space the ring has vacated, washed faintly in.
///
/// Without this the halo is a second hairline and reads as two rings, not
/// as one ring trailing something. Filling the annulus between the two
/// radii instead gives the halo a *volume* that grows as the ring falls
/// home, which is what makes the release something you notice rather than
/// something you can be told about. It is exactly zero at silence and at
/// every peak, because that is where the two radii meet.
private func wash(inner: Double, ghost: Double, mute: Double) -> some View {
let innerEdge = geometry.innerRadius(level: inner, mute: mute) + geometry.ringWidth / 2
let outerEdge = geometry.ghostRadius(level: ghost, mute: mute) - geometry.ghostWidth / 2
let band = max(outerEdge - innerEdge, 0)
return speakingRingStroke(innerEdge + band / 2, band, accent)
.blur(radius: geometry.ringWidth * 0.7)
.opacity(0.17 * ghost * (1 - mute))
}
/// The trailing halo: a soft bloom under a hairline.
///
/// The bloom is kept narrower than the gap it sits in — an earlier, wider
/// blur bridged the two strokes at every peak and turned them into one
/// fat soft band, which is the failure mode this whole component is about
/// avoiding. Alpha rises with the halo's own level, so silence is a
/// whisper of a line rather than a permanent double ring.
private func ghostLayer(_ level: Double, mute: Double) -> some View {
let radius = geometry.ghostRadius(level: level, mute: mute)
let alpha = 0.44 * (0.20 + 0.80 * level) * (1 - mute)
return ZStack {
speakingRingStroke(radius, geometry.ghostWidth * 1.8, accent)
.blur(radius: geometry.ringWidth * 0.42)
.opacity(alpha * 0.55)
speakingRingStroke(radius, geometry.ghostWidth, accent)
.opacity(alpha)
}
}
/// The ring that answers the voice, and collapses onto the face rim when
/// the mic is cut. Desaturating rather than recolouring means the collapse
/// works with any `accent` and there is no second colour to keep in step;
/// the small darkening on top is what stops a desaturated mid-green from
/// landing on the same light grey as an ordinary avatar border.
private func innerLayer(_ level: Double, mute: Double) -> some View {
speakingRingStroke(
geometry.innerRadius(level: level, mute: mute),
geometry.ringWidth,
accent
)
.saturation(1 - 0.88 * mute)
.brightness(-0.14 * mute)
.opacity(1 - 0.20 * mute)
}
/// The mute badge: a punched disc carrying a mic with a slash struck
/// through it.
///
/// Both windows are curves of the collapse rather than of the clock. The
/// disc arrives while the ring is still moving; the slash starts only once
/// the collapse is 72% done, which at this spring is about 130 ms in, and
/// finishes 170 ms later. Reversed on unmute the slash retracts first,
/// which is the same story told backwards.
private func badgeLayer(mute: Double) -> some View {
let arrive = speakingRingEase(mute, 0.30, 0.70)
let strike = speakingRingEase(mute, 0.72, 1.0)
let diameter = geometry.badge
let offset = geometry.badgeOffset
return ZStack {
// Punched clear of the collapsed ring in the surface colour, so
// the badge reads as part of the hardware rather than a sticker
// laid over it.
Circle()
.fill(surface)
.frame(width: geometry.badgeCut * 2, height: geometry.badgeCut * 2)
.opacity(arrive)
.offset(x: offset.x, y: offset.y)
// No microphone glyph under the slash, and that is a decision. A
// mic drawn at 0.24 of the avatar needs a dark casing around the
// slash so the two do not merge — both are the surface colour —
// and a casing wide enough to work is wider than the mic body,
// which leaves fragments rather than an icon. Rendered and looked
// at, it was mud. A single strike on a dark disc, next to a ring
// that has visibly collapsed, says the same thing and survives all
// the way down to 32 pt.
ZStack {
Circle().fill(badgeTint)
SpeakingSlashShape()
.trim(from: 0, to: strike)
.stroke(
surface,
style: StrokeStyle(lineWidth: diameter * 0.105, lineCap: .round)
)
}
.frame(width: diameter, height: diameter)
.scaleEffect(0.72 + 0.28 * arrive)
.opacity(arrive)
.offset(x: offset.x, y: offset.y)
}
}
}
/* ----------------------------------------------------------------- geometry */
/// Every radius in one place, all of it proportional.
///
/// Shared by the view and its frame so the layout box and the drawing cannot
/// drift apart — a ring that escapes its reserved box is how a `.clipShape`
/// further up the tree ends up biting a slice out of it.
private struct SpeakingGeometry {
let face: CGFloat
let gap: CGFloat
let ringWidth: CGFloat
let ghostWidth: CGFloat
let reach: CGFloat
/// Keeps the two strokes from touching at the moment their levels meet,
/// which they do at every peak: half of each stroke plus a little air.
let standoff: CGFloat
let badge: CGFloat
let badgeCut: CGFloat
let extent: CGFloat
init(size: CGFloat, ringWidth: CGFloat, reach: Double, badgeScale: Double) {
let width = max(ringWidth, 0.5)
face = size / 2
// Deliberately tight. The ring has to sit *on* the avatar at silence,
// or a pushed ring and a resting ring both just read as "a ring some
// distance away" and the displacement stops being legible.
gap = size * 0.030
self.ringWidth = width
ghostWidth = width * 0.38
self.reach = CGFloat(max(reach, 0)) * size
standoff = width / 2 + width * 0.19 + size * 0.022
badge = size * CGFloat(max(badgeScale, 0.05))
badgeCut = badge / 2 + width * 0.8
extent = max(
face + gap + width / 2 + self.reach + standoff + width * 0.19,
face + badgeCut
)
}
/// The inner ring's centre-line at silence.
var base: CGFloat { face + gap + ringWidth / 2 }
/// 4:30, in turns clockwise from twelve — where a mute badge lives on
/// every call tile ever shipped, and the one spot the initials never reach.
var badgeOffset: CGPoint {
let angle = 0.375 * 2 * Double.pi
return CGPoint(x: face * CGFloat(sin(angle)), y: face * CGFloat(-cos(angle)))
}
func innerRadius(level: Double, mute: Double) -> CGFloat {
let live = base + reach * CGFloat(min(max(level, 0), 1))
// Collapsed means the centre-line lands on the face rim, so the stroke
// straddles the edge it has been pulled down onto.
return live + (face - live) * CGFloat(mute)
}
func ghostRadius(level: Double, mute: Double) -> CGFloat {
let live = base + reach * CGFloat(min(max(level, 0), 1)) + standoff
return live + (face - live) * CGFloat(mute)
}
}
/* -------------------------------------------------------------------- drive */
/// The two levels for one frame, both closed-form functions of `t`.
///
/// The attack and the release are real DSP shapes, written as sums and maxima
/// over samples of the envelope rather than as filters over history — an FIR
/// approximation of a one-pole for the attack, and a peak-hold with a linear
/// release for the ghost. That is what buys a pinnable frame: 22 evaluations
/// of a three-sine function, no state, identical at any `t` however you got
/// there.
///
/// The timing constants are in seconds and the drive is in turns, so `period`
/// stretches the phrase while leaving the 45 ms attack and the 420 ms release
/// where they are. Speak faster and the halo overlaps the next syllable, which
/// is exactly what happens on a real meter.
private struct SpeakingLevel {
/// 0 at silence, 1 at full voice, through a 45 ms attack.
let inner: Double
/// The same level held at its peak and released over 420 ms. Never less
/// than `inner`, by construction — a halo inside its own ring would be a
/// bug you could not un-see.
let ghost: Double
init(t: Double, period: Double) {
let cycle = max(period, 0.4)
// Attack: an 8-tap FIR over three time constants, which at this
// bandwidth is indistinguishable from the one-pole it approximates and
// — unlike the one-pole — needs no previous frame to evaluate.
let attackSeconds = SpeakingRingKit.attackTau * 3 / Double(SpeakingRingKit.attackTaps)
let attackStep = attackSeconds / cycle
var sum = 0.0
var total = 0.0
for i in 0...SpeakingRingKit.attackTaps {
let w = exp(-Double(i) * attackSeconds / SpeakingRingKit.attackTau)
sum += w * SpeakingLevel.speech(t - Double(i) * attackStep)
total += w
}
let attacked = min(sum / total, 1)
inner = attacked
// Release: a peak hold, flat for 100 ms then eased to nothing at
// 420 ms. An exponential decay was the first thing tried and it is
// wrong here — by the time a gap between syllables is wide enough to
// see, an exponential halo has already shrunk back onto the ring and
// there is nothing left to see it against.
let releaseSeconds = SpeakingRingKit.release / Double(SpeakingRingKit.releaseTaps)
let releaseStep = releaseSeconds / cycle
var peak = attacked
for i in 1...SpeakingRingKit.releaseTaps {
let lag = Double(i) * releaseSeconds
let decay = 1 - speakingRingEase(lag, SpeakingRingKit.hold, SpeakingRingKit.release)
peak = max(peak, SpeakingLevel.speech(t - Double(i) * releaseStep) * decay)
}
ghost = min(max(peak, attacked), 1)
}
/// The synthesised envelope: syllables inside a phrase.
///
/// Three detuned sines summed and rectified. Rectifying is what puts real
/// gaps between the syllables — a product of sines only ever gives
/// tremolo, a hum with a wobble, and a ring that hums never looks like
/// anyone is talking. The phrase gate is zero at both ends of the loop and
/// stays zero for longer than the release window looks back, so `t = 1`
/// meets `t = 0` in silence with nothing trailing across the seam.
private static func speech(_ t: Double) -> Double {
let u = t - floor(t)
let gate = speakingRingEase(u, 0.06, 0.20) * (1 - speakingRingEase(u, 0.70, 0.87))
if gate <= 0 { return 0 }
let texture =
0.56 * sin(2 * .pi * 4.1 * u - 0.30)
+ 0.28 * sin(2 * .pi * 6.7 * u + 1.60)
+ 0.11 * sin(2 * .pi * 10.3 * u + 0.40)
// Normalised so the *attacked* level peaks at exactly 1.0 and `reach`
// means what it says. Measured off these constants; change a frequency
// and this changes with it.
return gate * max(0, texture) / SpeakingRingKit.levelNorm
}
}
/* ------------------------------------------------------------------ shapes */
/// The strike, top-leading to bottom-trailing, drawn by `trim` from its start
/// so the stroke grows in the direction a hand would draw it.
private struct SpeakingSlashShape: Shape {
func path(in rect: CGRect) -> Path {
let d = min(rect.width, rect.height)
var path = Path()
path.move(to: CGPoint(x: rect.midX - d * 0.30, y: rect.midY - d * 0.30))
path.addLine(to: CGPoint(x: rect.midX + d * 0.30, y: rect.midY + d * 0.30))
return path
}
}
/* ----------------------------------------------------------------- helpers */
/// Fixed timings, all in seconds except where a turn is the natural unit.
private enum SpeakingRingKit {
/// Silence, ring at its base radius.
static let restPhase = 0.0
/// A press shorter than this is a tap; anything longer was a hold.
static let tapWindow = 0.28
/// One-pole time constant for the inner ring. Short enough that a syllable
/// still snaps, long enough that the ring never clicks between frames.
static let attackTau = 0.045
/// Taps over three time constants. Eight is already indistinguishable from
/// the continuous filter at this bandwidth.
static let attackTaps = 7
/// Flat peak hold before the halo starts falling.
static let hold = 0.10
/// And gone by here.
static let release = 0.42
static let releaseTaps = 14
/// Peak of the attacked envelope before normalisation, measured over the
/// constants in `SpeakingLevel.speech`.
static let levelNorm = 0.84366
}
/// Smoothstep between two points — the one shaping function every out-of-step
/// track above is built from.
private func speakingRingEase(_ t: Double, _ start: Double, _ end: Double) -> Double {
guard end > start else { return t >= end ? 1 : 0 }
let x = min(max((t - start) / (end - start), 0), 1)
return x * x * (3 - 2 * x)
}
/// A circle whose *centre-line* sits at `radius`. `strokeBorder` strokes
/// inward from the frame, so the frame has to carry the stroke width or every
/// ring lands half a weight too far in.
private func speakingRingStroke(_ radius: CGFloat, _ width: CGFloat, _ colour: Color) -> some View {
Circle()
.strokeBorder(colour, lineWidth: width)
.frame(width: radius * 2 + width, height: radius * 2 + width)
}
/// FNV-1a over the name's UTF-8. Same name, same hue, on both platforms and
/// across launches, with nothing stored and no asset shipped.
private func speakingRingHue(_ name: String) -> Double {
var hash: UInt64 = 0xcbf2_9ce4_8422_2325
for byte in name.utf8 {
hash = (hash ^ UInt64(byte)) &* 0x100_0000_01b3
}
// Masked to 32 bits before the modulo. Swift's UInt64 remainder and Dart's
// signed-int remainder disagree for every name whose top bit is set —
// about half of them — so without this the same person is a different
// colour on iOS and on Android.
return Double((hash & 0xFFFF_FFFF) % 360)
}
private func speakingRingInitials(_ name: String) -> String {
let letters = name.split(separator: " ").prefix(2).compactMap(\.first)
return letters.isEmpty ? "?" : letters.map { String($0) }.joined().uppercased()
}
#Preview {
HStack(spacing: 24) {
SpeakingRing()
SpeakingRing(muted: true)
}
.padding(40)
}
SwiftUI note. The synthesised level is one call: replace `SpeakingLevel(t:period:)` in `SpeakingFrame.body` with your own metering to drive it from real audio, passing an RMS in 0...1 as both `inner` and `ghost` — the peak-hold keeps working. Swap `faceContent`'s body for `Image("sana").resizable().scaledToFill()` for a real photo; the mute desaturation is a filter on that group, so it survives the swap. Below about 32 pt drop `ringWidth` to 2 and the halo turns to mud — lose it rather than keep it. It repaints at display rate whenever the level is above zero, so a roster of twelve should hoist one `TimelineView` to the parent, pass `phase` down, and gate it on whether anyone is actually speaking.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27