SwiftUI Voice Orb Component
A microphone target whose ring reacts to real audio level, drawn in a single Canvas so 44 bars cost one draw instead of 44 layout passes. Uses asymmetric smoothing — fast attack, slow release — which is why it tracks speech instead of wobbling behind it, and two incommensurate waves so the ring never visibly loops.
Variants: Listening, Idle, Processing
Install the free SwiftUI voice orb
- Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
- Copy SKVoiceOrb.swift below into your project. It needs nothing else.
Usage
Usage.swift
SKVoiceOrb(state: recorder.state, level: recorder.level) { toggle() }Full source · 279 lines · MIT
SKVoiceOrb.swift
//
// SKVoiceOrb.swift
// Swift Kit UI
//
// Requires: SwiftKitUI.swift
//
// The listening target for voice input — a ring of bars that reacts to the
// microphone, drawn in a single `Canvas`.
//
// Two things make this feel like an instrument rather than a decoration.
//
// The first is asymmetric smoothing. A level meter that rises and falls at the
// same rate looks like jelly; ears expect a transient to appear instantly and
// decay slowly. So attack runs at roughly 14/s and release at 5/s, which is
// the same shape a hardware VU meter has and the reason this tracks speech
// instead of wobbling around behind it.
//
// The second is that the ring is driven by two incommensurate waves on top of
// the real level. A ring modulated by the level alone pumps uniformly — every
// bar the same length — which instantly reads as fake. The waves break that
// symmetry without inventing signal that is not there: amplitude is still the
// microphone, only its distribution around the ring is synthetic.
//
// Forty-four bars as forty-four `View`s would be forty-four layout passes per
// frame. `Canvas` draws them in one, which is what keeps this at 60fps next to
// a live transcript.
//
// Pass `level` from your audio engine — `AVAudioRecorder.averagePower`
// normalised to 0...1, or the RMS of an `AVAudioPCMBuffer`.
//
// Usage:
// SKVoiceOrb(state: recorder.state, level: recorder.level) { toggle() }
//
import SwiftUI
public enum SKVoiceState: Sendable, Hashable {
case idle, listening, processing
}
public struct SKVoiceOrb: View {
private let state: SKVoiceState
private let level: Double
private let size: CGFloat
private let onTap: (() -> Void)?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// The displayed level, chasing `level` with attack/release shaping.
@State private var smoothed: Double = 0
@State private var clock: Double = 0
/// Mirrors of the inputs, for the same reason `SKStreamingText` keeps one:
/// `.task` captures the view value, so the loop cannot see a `let` that
/// changed after it started. Without these the ring would freeze at
/// whatever the level was on the frame the task launched.
@State private var liveLevel: Double = 0
@State private var liveState: SKVoiceState = .idle
private let barCount = 44
public init(
state: SKVoiceState = .idle,
level: Double = 0,
size: CGFloat = 168,
onTap: (() -> Void)? = nil
) {
self.state = state
self.level = level
self.size = size
self.onTap = onTap
}
public var body: some View {
Button {
onTap?()
SK.Haptic.play(state == .listening ? .rigid : .medium)
} label: {
ZStack {
Canvas(opaque: false) { context, canvasSize in
draw(in: &context, size: canvasSize)
}
.frame(width: size, height: size)
// Drawing is pure; it reads state only through these.
.allowsHitTesting(false)
glyph
}
.frame(width: size, height: size)
.contentShape(Circle())
}
.buttonStyle(.skPress(scale: 0.96, haptic: nil))
.disabled(onTap == nil)
.onChange(of: level, initial: true) { _, new in liveLevel = new }
.onChange(of: state, initial: true) { _, new in liveState = new }
.task(id: reduceMotion) {
guard !reduceMotion else {
smoothed = state == .listening ? 0.45 : 0.12
return
}
await run()
}
.accessibilityLabel(accessibilityLabel)
.accessibilityAddTraits(.isButton)
}
// MARK: Drive
private func run() async {
var last = ContinuousClock.now
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(16))
let now = ContinuousClock.now
let dt = min(seconds(from: last, to: now), 0.1)
last = now
clock += dt
let target: Double
switch liveState {
case .listening: target = min(1, max(0, liveLevel))
case .processing: target = 0.42
case .idle: target = 0.07
}
// Attack fast, release slow. The whole character of the thing.
let rate = target > smoothed ? 14.0 : 5.0
smoothed += (target - smoothed) * min(1, rate * dt)
}
}
private func seconds(from start: ContinuousClock.Instant, to end: ContinuousClock.Instant) -> Double {
let d = end - start
return Double(d.components.seconds) + Double(d.components.attoseconds) * 1e-18
}
// MARK: Draw
private func draw(in context: inout GraphicsContext, size canvasSize: CGSize) {
let centre = CGPoint(x: canvasSize.width / 2, y: canvasSize.height / 2)
let radius = min(canvasSize.width, canvasSize.height) / 2
let innerRadius = radius * 0.46
let barWidth = radius * 0.035
let baseLength = radius * 0.07
let reactive = radius * 0.30
// Glow — scaled by level, so loud speech lights the whole control.
let glowRadius = innerRadius * (1.15 + smoothed * 0.5)
context.drawLayer { layer in
layer.addFilter(.blur(radius: radius * 0.16))
layer.fill(
Path(ellipseIn: CGRect(
x: centre.x - glowRadius, y: centre.y - glowRadius,
width: glowRadius * 2, height: glowRadius * 2
)),
with: .color(SK.Palette.accent.opacity(0.18 + smoothed * 0.30))
)
}
// Bars.
let head = clock * 2.4
for index in 0..<barCount {
let phase = Double(index) / Double(barCount) * 2 * .pi
let amplitude: Double
if state == .processing {
// A comet running the ring: distance from the head, wrapped.
var delta = abs(phase - head.truncatingRemainder(dividingBy: 2 * .pi))
if delta > .pi { delta = 2 * .pi - delta }
amplitude = exp(-delta * 2.1) * 0.95
} else {
// Two waves that do not share a period, so the ring never
// visibly loops. Amplitude is still the real level.
let wobble = 0.55
+ 0.30 * sin(phase * 3 + clock * 2.1)
+ 0.15 * sin(phase * 7 - clock * 1.3)
amplitude = smoothed * wobble
}
let length = baseLength + reactive * max(0, amplitude)
let rect = CGRect(
x: -barWidth / 2,
y: -(innerRadius + length),
width: barWidth,
height: length
)
let bar = Path(roundedRect: rect, cornerRadius: barWidth / 2)
.applying(
CGAffineTransform(rotationAngle: phase)
.concatenating(CGAffineTransform(translationX: centre.x, y: centre.y))
)
let intensity = 0.42 + min(1, amplitude * 1.6) * 0.58
context.fill(bar, with: .color(SK.Palette.accent.opacity(intensity)))
}
// Orb. Lighter at the top-left so it reads as a sphere lit from above
// rather than a flat disc.
let orbRect = CGRect(
x: centre.x - innerRadius, y: centre.y - innerRadius,
width: innerRadius * 2, height: innerRadius * 2
)
context.fill(
Path(ellipseIn: orbRect),
with: .radialGradient(
Gradient(colors: [
SK.Palette.accent.opacity(0.95),
SK.Palette.accent.opacity(0.62),
]),
center: CGPoint(x: centre.x - innerRadius * 0.28, y: centre.y - innerRadius * 0.34),
startRadius: 0,
endRadius: innerRadius * 1.5
)
)
context.stroke(
Path(ellipseIn: orbRect.insetBy(dx: 0.5, dy: 0.5)),
with: .color(Color.white.opacity(0.22)),
lineWidth: 1
)
}
// MARK: Glyph
private var glyph: some View {
Image(systemName: symbol)
.font(SK.Icon.symbol(size * 0.19, weight: .medium))
.foregroundStyle(Color.white)
.contentTransition(.symbolEffect(.replace))
.skAnimation(.snap, value: state)
}
private var symbol: String {
switch state {
case .idle: "mic.fill"
case .listening: "waveform"
case .processing: "ellipsis"
}
}
private var accessibilityLabel: String {
switch state {
case .idle: "Start voice input"
case .listening: "Listening. Tap to stop"
case .processing: "Processing your speech"
}
}
}
#Preview("SKVoiceOrb") {
@Previewable @State var state: SKVoiceState = .listening
@Previewable @State var level: Double = 0
VStack(spacing: SK.Space.xxl) {
SKVoiceOrb(state: state, level: level) {
state = state == .listening ? .idle : .listening
}
Text(String(describing: state).capitalized)
.font(SK.Type_.caption)
.foregroundStyle(SK.Palette.labelTertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(SK.Palette.surface)
// Stands in for a microphone: speech-shaped bursts with pauses, not a sine
// wave — a sine makes any orb look good and proves nothing.
.task {
while !Task.isCancelled {
let syllables = Int.random(in: 3...7)
for _ in 0..<syllables {
level = Double.random(in: 0.45...1.0)
try? await Task.sleep(for: .milliseconds(Int.random(in: 90...170)))
level = Double.random(in: 0.10...0.35)
try? await Task.sleep(for: .milliseconds(Int.random(in: 60...110)))
}
level = 0.04
try? await Task.sleep(for: .milliseconds(Int.random(in: 500...900)))
}
}
}
Questions
- Is this SwiftUI voice orb free for commercial apps?
- Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
- What does it depend on?
- SKVoiceOrb.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
- Which iOS versions does it support?
- iOS 17 and later, plus macOS 14.
Related components
Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.