Skip to content
Speaking Ring preview
An animated render of the SwiftUI source on this page.

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

SpeakingRing.swift
// 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)
}
iOS 17 · No dependencies

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

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27