mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
128 lines
5.1 KiB
Swift
128 lines
5.1 KiB
Swift
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+(kx−t′)²))^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)
|
||
}
|
||
}
|