Speech wave visualization in SwiftUI

# Speech wave visualization in SwiftUI

How to draw a wave representation of speech in SwiftUI, old Siri style.

For a personal project I needed a way to visually represent some speech audio in an iOS app. I decided to go with the old Siri wave animation, something like this.

Fresh from the 100 days of SwftUI animation lessons, I decided to implement it by myself in SwiftUI.

## Single wave

The first step is to create a single wave, as a `Shape`. I took the code from an existing repository linked at the end of the article.

``````struct Wave: Shape {
/// The frequency of the sinus wave. The higher the value, the more sinus wave peaks you will have.
/// Default: 1.5
var frequency: CGFloat = 1.5

/// The lines are joined stepwise, the more dense you draw, the more CPU power is used.
/// Default: 1
var density: CGFloat = 1.0

/// The phase shift that will be applied
var phase: CGFloat

/// The normed ampllitude of this wave, between 0 and 1.
var normedAmplitude: CGFloat

func path(in rect: CGRect) -> Path {
var path = Path()
let maxAmplitude = rect.height / 2.0
let mid = rect.width / 2

for x in Swift.stride(from:0, to: rect.width + self.density, by: self.density) {
// Parabolic scaling
let scaling = -pow(1 / mid * (x - mid), 2) + 1
let y = scaling * maxAmplitude * normedAmplitude * sin(CGFloat(2 * Double.pi) * self.frequency * (x / rect.width)  + self.phase) + rect.height / 2
if x == 0 {
path.move(to: CGPoint(x:x, y:y))
} else {
}
}

return path
}
}

struct ContentView: View {
var body: some View {
VStack {
Spacer()
Wave(phase: 1.5, normedAmplitude: 0.8)
.stroke(Color.green)
.frame(height: 300)
Spacer()
}
.background(Color.black)
.edgesIgnoringSafeArea(.all)
}
}
``````

What is happening in the `path` method is pure math, partially explained here. It is important to note the two main property we are going to change in our animation. The `normedAmplitude`, which is the amplitude of the audio, normalized between 0 and 1, and the `phase`, which will shift the wave along the y axis.

The result of the code above is:

## Multi wave

Now we want to combine some single waves into a snapshot of the full animation.

``````struct MultiWave: View {
var amplitude: CGFloat = 1.0
var color: Color = Color.green
var phase: CGFloat = 0.0

var body: some View {
ZStack {
ForEach((0...4), id: \.self) { count in
singleWave(count: count)
}
}
}

func singleWave(count: Int) -> some View {
let progress = 1.0 - CGFloat(count) / CGFloat(5)
let normedAmplitude = (1.5 * progress - 0.8) * self.amplitude
let alphaComponent = min(1.0, (progress/3.0*2.0) + (1.0/3.0))

return Wave(phase: phase, normedAmplitude: normedAmplitude)
.stroke(color.opacity(Double(alphaComponent)), lineWidth: 1.5 / CGFloat(count + 1))
}

}

struct ContentView: View {
var body: some View {
VStack {
Spacer()
MultiWave(amplitude: 0.8, color: .green, phase: 0.0)
.frame(height: 500)
Spacer()
}
.background(Color.black)
.edgesIgnoringSafeArea(.all)
}
}
``````

The code above creates 5 waves, the amplitude of each of them descreasing while also the line width decreases.

## It's time to animate

We start adding the animation in the content view:

``````struct ContentView: View {
@State private var amplitude: CGFloat = 0.8
@State private var phase: CGFloat = 0.0

var body: some View {
VStack {
Spacer()
MultiWave(amplitude: amplitude, color: .green, phase: phase)
.frame(height: 500)
.onAppear {
withAnimation(Animation.linear(duration: 0.1)
.repeatForever(autoreverses: false)
) {
self.amplitude = 0.5
self.phase -= 1.5
}
}
Spacer()
}
.background(Color.black)
.edgesIgnoringSafeArea(.all)
}
}
``````

Bacause `Wave` is a shape, we need to us an `AnimatablePair` to get the amplitude and phase to animate:

``````public var animatableData: AnimatablePair<CGFloat, CGFloat> {
get {
AnimatablePair(normedAmplitude, phase)
}

set {
self.normedAmplitude = newValue.first
self.phase = newValue.second
}
}
``````

Great. It works. Now we want to show it in a prettier way. We need to trigger an animation when a previous animation is completed. We will use some code from Antoine van der Lee and use it in our content view.

``````struct ContentView: View {
@State private var amplitude: CGFloat = 0.8
@State private var phase: CGFloat = 0.0
@State private var change: CGFloat = 0.1

var body: some View {
VStack {
Spacer()
MultiWave(amplitude: amplitude, color: .green, phase: phase)
.frame(height: 500)
.onAppear {
withAnimation(Animation.linear(duration: 0.1)
.repeatForever(autoreverses: false)
) {
self.amplitude = _nextAmplitude()
self.phase -= 1.5
}
}
.onAnimationCompleted(for: amplitude) {
withAnimation(.linear(duration: 0.1)){
self.amplitude = _nextAmplitude()
self.phase -= 1.5
}
}
Spacer()
}
.background(Color.black)
.edgesIgnoringSafeArea(.all)
}

private func _nextAmplitude() -> CGFloat {
// If the amplitude is too low or too high, cap it and go in the other direction.
if self.amplitude <= 0.01 {
self.change = 0.1
return 0.02
} else if self.amplitude > 0.9 {
self.change = -0.1
return 0.9
}

// Simply set the amplitude to whatever you need and the view will update itself.
let newAmplitude = self.amplitude + (self.change * CGFloat.random(in: 0.3...0.8))
return max(0.01, newAmplitude)
}
}
``````

We animate the wave to a new "initial" amplitude and phase the wave a bit. Then, when the animation is completed, we get a new, random, amplitude and animate again.

The math is taken from a library that does the same thing with `UIKit`.

The code is on a repository in GitHub:

## Wanna stay up-to-date with Flutter and Dart?

Subscribe to get a weekly email with the best articles about Flutter and Dart.

We won't send you spam. Unsubscribe at any time.