openclaw/apps/ios/Sources/Design/TalkSiriWave.swift
2026-07-05 21:38:54 -04:00

128 lines
5.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
/// iOS 9-style Siri waveform for the Talk screen, driven by the runtime mode.
/// Math adapted from noahchalifour/swiftui-siri-waveform-view (MIT), as packaged
/// by alfianlosari/SiriWaveView; redrawn with Canvas + TimelineView so lobes flow
/// continuously instead of re-randomizing per power change.
///
/// State rendering: off = flat static grey; connecting/thinking = slow low
/// breathing; listening = amplitude follows mic level; speech detected = full
/// power; agent speaking = strong TTS-style pulse.
struct TalkSiriWaveView: View {
var mode: TalkProWaveformMode
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private static let born = Date()
private static let monoColors: [Color] = [
OpenClawBrand.accent,
Color(red: 0.95, green: 0.45, blue: 0.30),
Color(red: 0.45, green: 0.08, blue: 0.12),
]
private static let inactiveColors: [Color] = [
Color(uiColor: .systemGray2),
Color(uiColor: .systemGray3),
Color(uiColor: .systemGray4),
]
var body: some View {
let frozen = self.reduceMotion || self.mode == .still
TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: frozen)) { timeline in
let time = frozen ? 0 : timeline.date.timeIntervalSince(Self.born)
let power = Self.power(for: self.mode, time: time)
Canvas { context, size in
let midY = size.height / 2
var line = Path()
line.move(to: CGPoint(x: 0, y: midY))
line.addLine(to: CGPoint(x: size.width, y: midY))
context.stroke(line, with: .color(.secondary.opacity(0.30)), lineWidth: 1)
// Screen blend pops on dark; opacity overlap reads better on light.
context.blendMode = self.colorScheme == .dark ? .screen : .normal
let opacity = self.colorScheme == .dark ? 0.9 : 0.55
for (index, color) in self.colors.enumerated() {
let path = Self.wavePath(
in: size,
time: time,
seed: Double(index) * 7.31,
power: power)
context.fill(path, with: .color(color.opacity(opacity)))
}
}
}
.opacity(self.mode == .still ? 0.6 : 1.0)
}
private var colors: [Color] {
self.mode == .still ? Self.inactiveColors : Self.monoColors
}
/// Per-state drive: thinking breathes low and slow, speaking pulses like
/// TTS playback, listening follows the live mic level.
private static func power(for mode: TalkProWaveformMode, time: Double) -> Double {
switch mode {
case .still:
0.05
case .indeterminate:
0.16 + 0.10 * (0.5 + 0.5 * sin(time * 1.6))
case let .level(level):
0.30 + 0.65 * min(max(level, 0), 1)
case .inputSpeech:
0.95
case .speaking:
0.70 * (0.55 + 0.45 * abs(sin(time * 5.0)))
}
}
/// One wave = max envelope of three drifting lobes, mirrored around the midline.
private static func wavePath(in size: CGSize, time: Double, seed: Double, power: Double) -> Path {
let midX = Double(size.width) / 2
let midY = Double(size.height) / 2
// Lobe parameters oscillate smoothly so peaks sweep back and forth
// across the line instead of scrolling off-screen.
let lobes: [(A: Double, k: Double, t: Double)] = (0..<3).map { index in
let f = Double(index)
let ampFrequency = 0.9 + 0.23 * f
let ampPhase = time * ampFrequency + seed * 2.4 + f * 2.1
let amp = 0.30 + 0.70 * (0.5 + 0.5 * sin(ampPhase))
let k = 0.62 + 0.11 * f
let driftFrequency = 0.45 + 0.17 * f
let driftPhase = time * driftFrequency + seed + f * 1.9
let t = 2.8 * sin(driftPhase)
return (A: amp, k: k, t: t)
}
var upper: [CGPoint] = []
var x = -midX
while x <= midX {
let graphX = x / (midX / 9.0)
var y: Double = 0
for lobe in lobes {
let amplitude = lobe.A * midY * power
y = max(y, Self.attenuatedSine(x: graphX, A: amplitude, k: lobe.k, t: lobe.t))
}
upper.append(CGPoint(x: midX + x, y: midY - y))
x += 2
}
var path = Path()
path.move(to: CGPoint(x: 0, y: midY))
path.addLines(upper)
for point in upper.reversed() {
path.addLine(to: CGPoint(x: point.x, y: 2 * midY - point.y))
}
path.closeSubpath()
return path
}
/// |A·sin(kx t)| shaped by the bell envelope g = (K/(K+(kxt)²))^K, K = 4.
private static func attenuatedSine(x: Double, A: Double, k: Double, t: Double) -> Double {
let sine = A * sin(k * x - t)
let tPrime = t - .pi / 2
let envelope = pow(4.0 / (4.0 + pow(k * x - tPrime, 2)), 4.0)
return abs(sine * envelope)
}
}